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"" end defp preview_page(%{page: :collection} = assigns) do assigns = preview_assigns(assigns) ~H"" 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"" 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"" 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"" 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"" 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"" 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"" end defp preview_page(%{page: :contact} = assigns) do assigns = preview_assigns(assigns) ~H"" 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"" 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