defmodule BerrypodWeb.Admin.Theme.Index do use BerrypodWeb, :live_view alias Berrypod.{Pages, 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("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do case Media.get_image(image_id) do nil -> {:noreply, socket} image -> {:ok, updated} = Media.update_image_metadata(image, %{alt: alt}) # Refresh the relevant assign so the template sees the new alt text socket = cond do socket.assigns.logo_image && socket.assigns.logo_image.id == image_id -> assign(socket, :logo_image, updated) socket.assigns.header_image && socket.assigns.header_image.id == image_id -> assign(socket, :header_image, updated) socket.assigns[:icon_image] && socket.assigns.icon_image && socket.assigns.icon_image.id == image_id -> assign(socket, :icon_image, updated) true -> socket end {:noreply, socket} end 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", header_nav_items: BerrypodWeb.ThemeHook.default_header_nav(), footer_nav_items: BerrypodWeb.ThemeHook.default_footer_nav() }) end # Unified preview — loads page definition, applies context, renders via PageRenderer 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(assigns) do slug = to_string(assigns.page) page = Pages.get_page(slug) assigns = assigns |> preview_assigns() |> assign(:page, page) |> preview_page_context(slug) extra = Pages.load_block_data(page.blocks, assigns) assigns = assign(assigns, extra) ~H"" end # Page-context data needed by specific page types in preview mode defp preview_page_context(assigns, "pdp") 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 |> assign(:product, product) |> assign(:gallery_images, build_gallery_images(product)) |> assign(:option_types, option_types) |> assign(:selected_options, selected_options) |> assign(:available_options, available_options) |> assign(:display_price, display_price) |> assign(:quantity, 1) |> assign(:option_urls, %{}) end defp preview_page_context(assigns, "cart") 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 |> assign(:cart_page_items, cart_items) |> assign(:cart_page_subtotal, subtotal) end defp preview_page_context(assigns, "about") do assign(assigns, :content_blocks, PreviewData.about_content()) end defp preview_page_context(assigns, "delivery") do assign(assigns, :content_blocks, PreviewData.delivery_content()) end defp preview_page_context(assigns, "privacy") do assign(assigns, :content_blocks, PreviewData.privacy_content()) end defp preview_page_context(assigns, "terms") do assign(assigns, :content_blocks, PreviewData.terms_content()) end defp preview_page_context(assigns, "error") do assign(assigns, %{ 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." }) end defp preview_page_context(assigns, _slug), do: assigns 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