2026-03-09 14:47:50 +00:00
|
|
|
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
|
2026-03-09 19:45:43 +00:00
|
|
|
alias Berrypod.{Media, Settings}
|
|
|
|
|
alias Berrypod.Workers.FaviconGeneratorWorker
|
2026-03-09 14:47:50 +00:00
|
|
|
|
|
|
|
|
# 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)
|
2026-03-09 19:45:43 +00:00
|
|
|
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)
|
2026-03-09 14:47:50 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@impl true
|
|
|
|
|
def handle_params(params, uri, socket) do
|
|
|
|
|
action = socket.assigns.live_action
|
|
|
|
|
prev_action = socket.assigns[:_current_page_action]
|
2026-03-09 19:45:43 +00:00
|
|
|
prev_path = socket.assigns[:_current_path]
|
2026-03-09 14:47:50 +00:00
|
|
|
module = @page_modules[action]
|
2026-03-09 19:45:43 +00:00
|
|
|
parsed_uri = URI.parse(uri)
|
|
|
|
|
current_path = parsed_uri.path
|
2026-03-09 14:47:50 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-03-09 18:34:45 +00:00
|
|
|
# Page type changed - clear previous page's assigns and call init
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:_current_page_action, action)
|
|
|
|
|
|> clear_page_specific_assigns(prev_action)
|
2026-03-09 14:47:50 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-09 19:45:43 +00:00
|
|
|
# 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)
|
|
|
|
|
|
2026-03-09 14:47:50 +00:00
|
|
|
# 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"""
|
|
|
|
|
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
|
|
|
"""
|
|
|
|
|
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
|
2026-03-09 18:34:45 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-03-09 14:47:50 +00:00
|
|
|
end
|