defmodule BerrypodWeb.Shop.Page do @moduledoc """ Unified shop LiveView that handles all shop pages. Using a single LiveView enables `patch` navigation between pages, preserving socket state (including editor state) across transitions. """ use BerrypodWeb, :live_view alias BerrypodWeb.Shop.Pages alias Berrypod.{Media, Settings} alias Berrypod.Workers.FaviconGeneratorWorker # Map live_action atoms to page handler modules @page_modules %{ home: Pages.Home, product: Pages.Product, collection: Pages.Collection, cart: Pages.Cart, contact: Pages.Contact, search: Pages.Search, orders: Pages.Orders, order_detail: Pages.OrderDetail, checkout_success: Pages.CheckoutSuccess, custom_page: Pages.CustomPage, # Content pages all use the same module about: Pages.Content, delivery: Pages.Content, privacy: Pages.Content, terms: Pages.Content } # Pages that need session data passed to init @session_pages [:orders, :order_detail] @impl true def mount(_params, session, socket) do # Store session for pages that need it (orders, order_detail) socket = assign(socket, :_session, session) # Configure uploads only for admin users (theme editor image uploads) socket = if socket.assigns[:is_admin] do socket |> allow_upload(:theme_logo_upload, accept: ~w(.png .jpg .jpeg .webp .svg), max_entries: 1, max_file_size: 2_000_000, auto_upload: true, progress: &handle_theme_upload_progress/3 ) |> allow_upload(:theme_header_upload, accept: ~w(.png .jpg .jpeg .webp), max_entries: 1, max_file_size: 5_000_000, auto_upload: true, progress: &handle_theme_upload_progress/3 ) |> allow_upload(:theme_icon_upload, accept: ~w(.png .jpg .jpeg .webp .svg), max_entries: 1, max_file_size: 5_000_000, auto_upload: true, progress: &handle_theme_upload_progress/3 ) else socket end {:ok, socket} end # Handle theme image upload progress (logo, header, icon) defp handle_theme_upload_progress(:theme_logo_upload, entry, socket) do if entry.done? do consume_uploaded_entries(socket, :theme_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_editor_settings] && socket.assigns.theme_editor_settings.use_logo_as_icon do enqueue_favicon_generation(image.id) end {:noreply, assign(socket, :theme_editor_logo_image, image) |> assign(:logo_image, image)} _ -> {:noreply, socket} end else {:noreply, socket} end end defp handle_theme_upload_progress(:theme_header_upload, entry, socket) do if entry.done? do consume_uploaded_entries(socket, :theme_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 | _] -> socket = socket |> assign(:theme_editor_header_image, image) |> assign(:header_image, image) |> recompute_header_contrast() {:noreply, socket} _ -> {:noreply, socket} end else {:noreply, socket} end end defp handle_theme_upload_progress(:theme_icon_upload, entry, socket) do if entry.done? do consume_uploaded_entries(socket, :theme_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, :theme_editor_icon_image, image) |> assign(:icon_image, image)} _ -> {:noreply, socket} end else {:noreply, socket} end end defp enqueue_favicon_generation(source_image_id) do %{source_image_id: source_image_id} |> FaviconGeneratorWorker.new() |> Oban.insert() end defp recompute_header_contrast(socket) do header_image = socket.assigns[:theme_editor_header_image] theme_settings = socket.assigns[:theme_editor_settings] warning = if theme_settings && theme_settings.header_background_enabled && header_image do text_color = Berrypod.Theme.Contrast.text_color_for_mood(theme_settings.mood) colors = Berrypod.Theme.Contrast.parse_dominant_colors(header_image.dominant_colors) Berrypod.Theme.Contrast.analyze_header_contrast(colors, text_color) else :ok end assign(socket, :theme_editor_contrast_warning, warning) end @impl true def handle_params(params, uri, socket) do action = socket.assigns.live_action prev_action = socket.assigns[:_current_page_action] prev_path = socket.assigns[:_current_path] module = @page_modules[action] parsed_uri = URI.parse(uri) current_path = parsed_uri.path # Clean up previous page if needed (e.g., unsubscribe from PubSub) socket = maybe_cleanup_previous_page(socket, prev_action) socket = if action != prev_action do # Page type changed - clear previous page's assigns and call init socket = socket |> assign(:_current_page_action, action) |> clear_page_specific_assigns(prev_action) result = if action in @session_pages do module.init(socket, params, uri, socket.assigns._session) else module.init(socket, params, uri) end case result do {:noreply, socket} -> socket {:redirect, socket} -> socket end else socket end # After page init, sync editor state if editing and page changed socket = maybe_sync_editing_blocks(socket) # Scroll to top on navigation (different path, not just query param changes) socket = if prev_path && prev_path != current_path do Phoenix.LiveView.push_event(socket, "scroll-top", %{}) else socket end socket = assign(socket, :_current_path, current_path) # Always call handle_params for URL changes case module.handle_params(params, uri, socket) do {:noreply, socket} -> {:noreply, socket} end end # If editing and we navigated to a different page, reload editing_blocks defp maybe_sync_editing_blocks(socket) do page = socket.assigns[:page] editing = socket.assigns[:editing] editor_page_slug = socket.assigns[:editor_page_slug] if editing && page && page.slug != editor_page_slug do # Page changed while editing - reload editing state for the new page allowed = Berrypod.Pages.BlockTypes.allowed_for(page.slug) at_defaults = Berrypod.Pages.Defaults.matches_defaults?(page.slug, page.blocks) socket |> assign(:editing_blocks, page.blocks) |> assign(:editor_page_slug, page.slug) |> assign(:editor_dirty, false) |> assign(:editor_at_defaults, at_defaults) |> assign(:editor_history, []) |> assign(:editor_future, []) |> assign(:editor_expanded, MapSet.new()) |> assign(:editor_allowed_blocks, allowed) else socket end end @impl true def handle_event(event, params, socket) do module = @page_modules[socket.assigns.live_action] case module.handle_event(event, params, socket) do :cont -> # Event not handled by page module, let hooks handle it {:noreply, socket} {:noreply, socket} -> {:noreply, socket} end end @impl true def handle_info(msg, socket) do module = @page_modules[socket.assigns.live_action] # Check if the module defines handle_info if function_exported?(module, :handle_info, 2) do case module.handle_info(msg, socket) do :cont -> {:noreply, socket} {:noreply, socket} -> {:noreply, socket} end else {:noreply, socket} end end @impl true def render(assigns) do # Cart page needs extra assigns computed at render time assigns = if assigns.live_action == :cart do Pages.Cart.compute_assigns(assigns) else assigns end ~H""" """ end # Clean up previous page state when transitioning defp maybe_cleanup_previous_page(socket, :checkout_success) do Pages.CheckoutSuccess.cleanup(socket) end defp maybe_cleanup_previous_page(socket, _), do: socket # Clear assigns that are specific to each page type to prevent stale data # from bleeding into the next page's render defp clear_page_specific_assigns(socket, :product) do socket |> assign(:product, nil) |> assign(:all_images, nil) |> assign(:variants, nil) |> assign(:option_types, nil) |> assign(:selected_variant, nil) |> assign(:selected_options, nil) |> assign(:available_options, nil) |> assign(:display_price, nil) |> assign(:gallery_images, nil) |> assign(:option_urls, nil) |> assign(:related_products, nil) |> assign(:json_ld, nil) end defp clear_page_specific_assigns(socket, :collection) do socket |> assign(:products, nil) |> assign(:pagination, nil) |> assign(:current_sort, nil) |> assign(:collection_title, nil) |> assign(:collection_slug, nil) end defp clear_page_specific_assigns(socket, :contact) do socket |> assign(:contact_form, nil) |> assign(:lookup_form, nil) |> assign(:looked_up_orders, nil) end defp clear_page_specific_assigns(socket, :search) do assign(socket, :search_results, nil) end defp clear_page_specific_assigns(socket, :orders) do assign(socket, :orders, nil) end defp clear_page_specific_assigns(socket, :order_detail) do socket |> assign(:order, nil) |> assign(:email_form, nil) end defp clear_page_specific_assigns(socket, :checkout_success) do socket |> assign(:order, nil) |> assign(:js_purchase_tracked, nil) |> assign(:css_purchase_tracked, nil) end defp clear_page_specific_assigns(socket, _), do: socket end