berrypod/lib/berrypod_web/live/admin/theme/index.ex

567 lines
16 KiB
Elixir
Raw Normal View History

defmodule BerrypodWeb.Admin.Theme.Index do
use BerrypodWeb, :live_view
alias Berrypod.Settings
alias Berrypod.Media
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
alias Berrypod.Workers.FaviconGeneratorWorker
@impl true
def mount(_params, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
preview_data = %{
products: PreviewData.products(),
cart_items: PreviewData.cart_items(),
testimonials: PreviewData.testimonials(),
categories: PreviewData.categories()
}
logo_image = Media.get_logo()
header_image = Media.get_header()
icon_image = Media.get_icon()
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:preview_page, :home)
|> assign(:presets_with_descriptions, Presets.all_with_descriptions())
|> assign(:active_preset, active_preset)
|> assign(:preview_data, preview_data)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:icon_image, icon_image)
|> assign(:customise_open, false)
|> assign(:sidebar_collapsed, false)
|> assign(:cart_drawer_open, false)
|> allow_upload(:logo_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 2_000_000,
auto_upload: true,
progress: &handle_progress/3
)
|> allow_upload(:header_upload,
accept: ~w(.png .jpg .jpeg .webp),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_progress/3
)
|> allow_upload(:icon_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
end
defp handle_progress(:logo_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "logo") do
{:ok, image} ->
Settings.update_theme_settings(%{logo_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
end)
|> case do
[image | _] ->
# Trigger favicon generation if using logo as icon
if socket.assigns.theme_settings.use_logo_as_icon do
enqueue_favicon_generation(image.id)
end
{:noreply, assign(socket, :logo_image, image)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
defp handle_progress(:header_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "header") do
{:ok, image} ->
Settings.update_theme_settings(%{header_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
end)
|> case do
[image | _] ->
{:noreply, assign(socket, :header_image, image)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
defp handle_progress(:icon_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :icon_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "icon") do
{:ok, image} ->
Settings.update_theme_settings(%{icon_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
end)
|> case do
[image | _] ->
enqueue_favicon_generation(image.id)
{:noreply, assign(socket, :icon_image, image)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, preset_atom)
|> put_flash(:info, "Applied #{preset_name} preset")
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to apply preset")}
end
end
@impl true
def handle_event("change_preview_page", %{"page" => page_name}, socket) do
page_atom = String.to_existing_atom(page_name)
socket =
socket
|> assign(:preview_page, page_atom)
|> push_event("scroll-preview-top", %{})
{:noreply, socket}
end
@impl true
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("update_setting", %{"field" => field} = params, socket) do
# For phx-change events from select/input elements, the value comes from the name attribute
value = params[field] || params["#{field}_text"] || params["value"]
if value do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("update_color", %{"field" => field, "value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("toggle_setting", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_value = Map.get(socket.assigns.theme_settings, field_atom)
attrs = %{field_atom => !current_value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
# Trigger favicon regeneration when the icon source changes
if field_atom == :use_logo_as_icon do
maybe_enqueue_favicon_from_settings(theme_settings, socket.assigns)
end
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully")
{:noreply, socket}
end
@impl true
def handle_event("remove_logo", _params, socket) do
if logo = socket.assigns.logo_image do
Media.delete_image(logo)
end
Settings.update_theme_settings(%{logo_image_id: nil})
socket =
socket
|> assign(:logo_image, nil)
|> put_flash(:info, "Logo removed")
{:noreply, socket}
end
@impl true
def handle_event("remove_header", _params, socket) do
if header = socket.assigns.header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
socket =
socket
|> assign(:header_image, nil)
|> put_flash(:info, "Header image removed")
{:noreply, socket}
end
@impl true
def handle_event("remove_icon", _params, socket) do
if icon = socket.assigns.icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
socket =
socket
|> assign(:icon_image, nil)
|> put_flash(:info, "Icon removed")
{:noreply, socket}
end
@impl true
def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do
upload_atom = String.to_existing_atom(upload_name)
{:noreply, cancel_upload(socket, upload_atom, ref)}
end
@impl true
def handle_event("toggle_customise", _params, socket) do
{:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)}
end
@impl true
def handle_event("toggle_sidebar", _params, socket) do
{:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)}
end
@impl true
def handle_event("open_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, true)}
end
@impl true
def handle_event("close_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, false)}
end
@impl true
def handle_event("noop", _params, socket) do
{:noreply, socket}
end
def error_to_string(:too_large), do: "File is too large"
def error_to_string(:too_many_files), do: "Too many files"
def error_to_string(:not_accepted), do: "File type not accepted"
def error_to_string(err), do: inspect(err)
defp enqueue_favicon_generation(source_image_id) do
%{source_image_id: source_image_id}
|> FaviconGeneratorWorker.new()
|> Oban.insert()
end
defp maybe_enqueue_favicon_from_settings(theme_settings, assigns) do
source_id =
if theme_settings.use_logo_as_icon do
case assigns.logo_image do
%{id: id} -> id
_ -> nil
end
else
case assigns.icon_image do
%{id: id} -> id
_ -> nil
end
end
if source_id, do: enqueue_favicon_generation(source_id)
end
defp preview_assigns(assigns) do
assign(assigns, %{
mode: :preview,
products: assigns.preview_data.products,
categories: assigns.preview_data.categories,
cart_items: PreviewData.cart_drawer_items(),
cart_count: 2,
cart_subtotal: "£72.00"
})
end
# Preview page component — delegates to shared PageTemplates with preview-specific assigns
attr :page, :atom, required: true
attr :preview_data, :map, required: true
attr :theme_settings, :map, required: true
attr :logo_image, :any, required: true
attr :header_image, :any, required: true
attr :cart_drawer_open, :boolean, default: false
defp preview_page(%{page: :home} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.home {assigns} />"
end
defp preview_page(%{page: :collection} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.collection {assigns} />"
end
defp preview_page(%{page: :pdp} = assigns) do
product = List.first(assigns.preview_data.products)
option_types = Map.get(product, :option_types) || []
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} =
case variants do
[first | _] -> {first.options, first}
[] -> {%{}, nil}
end
available_options =
Enum.reduce(option_types, %{}, fn opt, acc ->
values = Enum.map(opt.values, & &1.title)
Map.put(acc, opt.name, values)
end)
display_price =
if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns =
assigns
|> preview_assigns()
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
end
defp preview_page(%{page: :cart} = assigns) do
cart_items = assigns.preview_data.cart_items
subtotal =
Enum.reduce(cart_items, 0, fn item, acc ->
acc + item.product.cheapest_price * item.quantity
end)
assigns =
assigns
|> preview_assigns()
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
~H"<BerrypodWeb.PageTemplates.cart {assigns} />"
end
defp preview_page(%{page: :about} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "about",
hero_title: "About the studio",
hero_description: "Your story goes here this is sample content for the demo shop",
hero_background: :sunken,
image_src: "/mockups/night-sky-blanket-3",
image_alt: "Night sky blanket draped over a chair",
content_blocks: PreviewData.about_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :delivery} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "delivery",
hero_title: "Delivery & returns",
hero_description: "Everything you need to know about shipping and returns",
content_blocks: PreviewData.delivery_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :privacy} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "privacy",
hero_title: "Privacy policy",
hero_description: "How we handle your personal information",
content_blocks: PreviewData.privacy_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :terms} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "terms",
hero_title: "Terms of service",
hero_description: "The legal bits",
content_blocks: PreviewData.terms_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :contact} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.contact {assigns} />"
end
defp preview_page(%{page: :error} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
error_code: "404",
error_title: "Page Not Found",
error_description:
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
})
~H"<BerrypodWeb.PageTemplates.error {assigns} />"
end
defp build_gallery_images(product) do
alias Berrypod.Products.ProductImage
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
|> case do
[] -> []
urls -> urls
end
end
end