consolidate shop pages into unified LiveView for editor state persistence
All checks were successful
deploy / deploy (push) Successful in 1m27s

Replace individual shop LiveViews with a single Shop.Page that dispatches
to page modules based on live_action. This enables patch navigation between
pages, preserving socket state (including editor state) across transitions.

Changes:
- Add Shop.Page unified LiveView with handle_params dispatch
- Extract page logic into Shop.Pages.* modules (Home, Product, Collection, etc.)
- Update router to use Shop.Page with live_action for all shop routes
- Change navigate= to patch= in shop component links
- Add maybe_sync_editing_blocks to reload editor state when page changes
- Track editor_page_slug to detect cross-page navigation while editing
- Fix picture element height when hover image disabled
- Extract ThemeEditor components for shared use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-09 14:47:50 +00:00
parent ae0a149ecd
commit bb5d220079
29 changed files with 1410 additions and 1037 deletions

View File

@@ -522,561 +522,39 @@
<% end %>
<!-- Presets Section -->
<div class="theme-section">
<label class="theme-section-label">Start with a preset</label>
<div class="theme-presets">
<%= for {preset_name, description} <- @presets_with_descriptions do %>
<button
type="button"
phx-click="apply_preset"
phx-value-preset={preset_name}
class={[
"theme-preset",
@active_preset == preset_name && "theme-preset-active"
]}
>
<div class="theme-preset-name">{preset_name}</div>
<div class="theme-preset-desc">{description}</div>
</button>
<% end %>
</div>
</div>
<.preset_grid
presets={@presets_with_descriptions}
active_preset={@active_preset}
event_prefix=""
label="Start with a preset"
/>
<!-- Accent Colors -->
<div class="theme-section">
<label class="theme-section-label">Accent colour</label>
<form
id="accent-color-form"
phx-change="update_color"
phx-value-field="accent_color"
phx-hook="ColorSync"
>
<div class="theme-color-row">
<input
type="color"
id="accent-color-picker"
name="value"
value={@theme_settings.accent_color}
class="theme-color-swatch"
/>
<span class="theme-color-value">{@theme_settings.accent_color}</span>
</div>
</form>
</div>
<div class="theme-section">
<label class="theme-section-label">Hover colour</label>
<form
id="secondary-accent-color-form"
phx-change="update_color"
phx-value-field="secondary_accent_color"
phx-hook="ColorSync"
>
<div class="theme-color-row">
<input
type="color"
id="secondary-accent-color-picker"
name="value"
value={@theme_settings.secondary_accent_color}
class="theme-color-swatch"
/>
<span class="theme-color-value">{@theme_settings.secondary_accent_color}</span>
</div>
</form>
</div>
<div class="theme-section">
<label class="theme-section-label">Sale colour</label>
<form
id="sale-color-form"
phx-change="update_color"
phx-value-field="sale_color"
phx-hook="ColorSync"
>
<div class="theme-color-row">
<input
type="color"
id="sale-color-picker"
name="value"
value={@theme_settings.sale_color}
class="theme-color-swatch"
/>
<span class="theme-color-value">{@theme_settings.sale_color}</span>
</div>
</form>
</div>
<.color_picker
field="accent_color"
label="Accent colour"
value={@theme_settings.accent_color}
event_prefix=""
/>
<.color_picker
field="secondary_accent_color"
label="Hover colour"
value={@theme_settings.secondary_accent_color}
event_prefix=""
/>
<.color_picker
field="sale_color"
label="Sale colour"
value={@theme_settings.sale_color}
event_prefix=""
/>
<!-- Customise Section -->
<details
class="theme-customise"
id="customise-section"
open={@customise_open}
>
<summary class="theme-customise-summary" phx-click="toggle_customise">
<span class="theme-customise-label">Customise</span>
<svg
class="theme-customise-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="theme-customise-body">
<!-- Typography Group -->
<div class="theme-group">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="4 7 4 4 20 4 20 7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</svg>
<span class="theme-group-title">Typography</span>
</div>
<div class="theme-field">
<label class="theme-section-label">Font style</label>
<div class="theme-chips">
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="typography"
phx-value-setting_value={typo}
class={[
"theme-chip",
@theme_settings.typography == typo && "theme-chip-active"
]}
>
{typo}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Font size</label>
<div class="theme-chips">
<%= for {value, label} <- [{"small", "Small"}, {"medium", "Medium"}, {"large", "Large"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="font_size"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.font_size == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Heading weight</label>
<div class="theme-chips">
<%= for {value, label} <- [{"regular", "Regular"}, {"medium", "Medium"}, {"bold", "Bold"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="heading_weight"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.heading_weight == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
</div>
<!-- Colours Group -->
<div class="theme-group">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="theme-group-title">Colours</span>
</div>
<div class="theme-field">
<label class="theme-section-label">Colour mood</label>
<div class="theme-chips">
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="mood"
phx-value-setting_value={mood}
class={["theme-chip", @theme_settings.mood == mood && "theme-chip-active"]}
>
{mood}
</button>
<% end %>
</div>
</div>
</div>
<!-- Layout Group -->
<div class="theme-group">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span class="theme-group-title">Layout</span>
</div>
<div class="theme-field">
<label class="theme-section-label">Product grid</label>
<div class="theme-chips">
<%= for cols <- ["2", "3", "4"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="grid_columns"
phx-value-setting_value={cols}
class={[
"theme-chip",
@theme_settings.grid_columns == cols && "theme-chip-active"
]}
>
{cols} columns
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Density</label>
<div class="theme-chips">
<%= for density <- ["spacious", "balanced", "compact"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="density"
phx-value-setting_value={density}
class={[
"theme-chip",
@theme_settings.density == density && "theme-chip-active"
]}
>
{density}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Header layout</label>
<div class="theme-chips">
<%= for layout <- ["standard", "centered", "left"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="header_layout"
phx-value-setting_value={layout}
class={[
"theme-chip",
@theme_settings.header_layout == layout && "theme-chip-active"
]}
>
{layout}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.announcement_bar}
phx-click="toggle_setting"
phx-value-field="announcement_bar"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Announcement bar</span>
</label>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.sticky_header}
phx-click="toggle_setting"
phx-value-field="sticky_header"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Sticky header</span>
</label>
</div>
</div>
<!-- Shape Group -->
<div class="theme-group-flush">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
<span class="theme-group-title">Shape</span>
</div>
<div class="theme-field">
<label class="theme-section-label">Corner style</label>
<div class="theme-chips">
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="shape"
phx-value-setting_value={shape}
class={["theme-chip", @theme_settings.shape == shape && "theme-chip-active"]}
>
{shape}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Card shadow</label>
<div class="theme-chips">
<%= for {value, label} <- [{"none", "None"}, {"sm", "Subtle"}, {"md", "Medium"}, {"lg", "Strong"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="card_shadow"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.card_shadow == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Button style</label>
<div class="theme-chips">
<%= for {value, label} <- [{"filled", "Filled"}, {"outline", "Outline"}, {"soft", "Soft"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="button_style"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.button_style == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
</div>
<!-- Products Group -->
<div class="theme-group">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span class="theme-group-title">Products</span>
</div>
<div class="theme-field">
<label class="theme-section-label">Content width</label>
<div class="theme-chips">
<%= for width <- ["contained", "wide", "full"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="layout_width"
phx-value-setting_value={width}
class={[
"theme-chip",
@theme_settings.layout_width == width && "theme-chip-active"
]}
>
{width}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Image aspect ratio</label>
<div class="theme-chips">
<%= for {value, label} <- [{"square", "Square"}, {"portrait", "Portrait"}, {"landscape", "Landscape"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="image_aspect_ratio"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.image_aspect_ratio == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="theme-section-label">Product text alignment</label>
<div class="theme-chips">
<%= for {value, label} <- [{"left", "Left"}, {"center", "Centre"}] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="product_text_align"
phx-value-setting_value={value}
class={[
"theme-chip",
@theme_settings.product_text_align == value && "theme-chip-active"
]}
>
{label}
</button>
<% end %>
</div>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.hover_image}
phx-click="toggle_setting"
phx-value-field="hover_image"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Second image on hover</span>
</label>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.show_prices}
phx-click="toggle_setting"
phx-value-field="show_prices"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Show prices</span>
</label>
</div>
</div>
<!-- Product Page Group -->
<div class="theme-group-flush">
<div class="theme-group-header">
<svg
class="theme-group-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
</svg>
<span class="theme-group-title">Product page</span>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.pdp_trust_badges}
phx-click="toggle_setting"
phx-value-field="pdp_trust_badges"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Trust badges</span>
</label>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.pdp_reviews}
phx-click="toggle_setting"
phx-value-field="pdp_reviews"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Reviews section</span>
</label>
</div>
<div class="theme-field">
<label class="admin-check-label">
<input
type="checkbox"
checked={@theme_settings.pdp_related_products}
phx-click="toggle_setting"
phx-value-field="pdp_related_products"
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Related products</span>
</label>
</div>
</div>
</div>
</details>
<.customise_accordion
theme_settings={@theme_settings}
customise_open={@customise_open}
event_prefix=""
/>
<% end %>
</div>

View File

@@ -1,20 +0,0 @@
defmodule BerrypodWeb.Shop.Cart do
use BerrypodWeb, :live_view
alias Berrypod.{Cart, Pages}
@impl true
def mount(_params, _session, socket) do
page = Pages.get_page("cart")
{:ok, socket |> assign(:page_title, "Cart") |> assign(:page, page)}
end
@impl true
def render(assigns) do
assigns = assign(assigns, :cart_page_subtotal, Cart.calculate_subtotal(assigns.cart_items))
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
end

View File

@@ -1,58 +0,0 @@
defmodule BerrypodWeb.Shop.CheckoutSuccess do
use BerrypodWeb, :live_view
alias Berrypod.{Analytics, Orders, Pages}
@impl true
def mount(%{"session_id" => session_id}, _session, socket) do
order = Orders.get_order_by_stripe_session(session_id)
# Subscribe to order status updates (webhook may arrive after redirect)
if order && connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
end
# Track purchase event
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
attrs =
BerrypodWeb.AnalyticsHook.attrs(socket)
|> Map.merge(%{pathname: "/checkout/success", revenue: order.total})
Analytics.track_event("purchase", attrs)
end
# Clear the cart after successful checkout
socket =
if order && connected?(socket) do
BerrypodWeb.CartHook.broadcast_and_update(socket, [])
else
socket
end
page = Pages.get_page("checkout_success")
socket =
socket
|> assign(:page_title, "Order confirmed")
|> assign(:order, order)
|> assign(:page, page)
{:ok, socket}
end
def mount(_params, _session, socket) do
{:ok, redirect(socket, to: ~p"/")}
end
@impl true
def handle_info({:order_paid, order}, socket) do
{:noreply, assign(socket, :order, order)}
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
end

View File

@@ -6,6 +6,11 @@ defmodule BerrypodWeb.Shop.ComingSoon do
{:ok, assign(socket, :page_title, "Coming soon")}
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""

View File

@@ -0,0 +1,154 @@
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
# 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)
{:ok, assign(socket, :_session, session)}
end
@impl true
def handle_params(params, uri, socket) do
action = socket.assigns.live_action
prev_action = socket.assigns[:_current_page_action]
module = @page_modules[action]
# 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 - call init
socket = assign(socket, :_current_page_action, 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)
# 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
end

View File

@@ -0,0 +1,31 @@
defmodule BerrypodWeb.Shop.Pages.Cart do
@moduledoc """
Cart page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
alias Berrypod.Pages
def init(socket, _params, _uri) do
page = Pages.get_page("cart")
socket =
socket
|> assign(:page_title, "Cart")
|> assign(:page, page)
{:noreply, socket}
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
def handle_event(_event, _params, _socket), do: :cont
# Called from render to compute the subtotal
def compute_assigns(assigns) do
Map.put(assigns, :cart_page_subtotal, Berrypod.Cart.calculate_subtotal(assigns.cart_items))
end
end

View File

@@ -0,0 +1,82 @@
defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
@moduledoc """
Checkout success page handler for the unified Shop.Page LiveView.
Handles PubSub subscription for order status updates.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [connected?: 1, redirect: 2]
alias Berrypod.{Analytics, Orders, Pages}
def init(socket, %{"session_id" => session_id}, _uri) do
order = Orders.get_order_by_stripe_session(session_id)
# Subscribe to order status updates (webhook may arrive after redirect)
if order && connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
end
# Track purchase event
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
attrs =
BerrypodWeb.AnalyticsHook.attrs(socket)
|> Map.merge(%{pathname: "/checkout/success", revenue: order.total})
Analytics.track_event("purchase", attrs)
end
# Clear the cart after successful checkout
socket =
if order && connected?(socket) do
BerrypodWeb.CartHook.broadcast_and_update(socket, [])
else
socket
end
# Track subscription for cleanup when leaving this page
socket =
if order do
assign(socket, :checkout_order_subscription, "order:#{order.id}:status")
else
socket
end
page = Pages.get_page("checkout_success")
socket =
socket
|> assign(:page_title, "Order confirmed")
|> assign(:order, order)
|> assign(:page, page)
{:noreply, socket}
end
def init(socket, _params, _uri) do
{:redirect, redirect(socket, to: "/")}
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
def handle_event(_event, _params, _socket), do: :cont
def handle_info({:order_paid, order}, socket) do
{:noreply, assign(socket, :order, order)}
end
def handle_info(_msg, _socket), do: :cont
# Called when leaving this page to clean up subscription
def cleanup(socket) do
if topic = socket.assigns[:checkout_order_subscription] do
Phoenix.PubSub.unsubscribe(Berrypod.PubSub, topic)
end
socket
|> assign(:checkout_order_subscription, nil)
|> assign(:order, nil)
end
end

View File

@@ -1,5 +1,10 @@
defmodule BerrypodWeb.Shop.Collection do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Collection do
@moduledoc """
Collection page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
alias Berrypod.{Pages, Pagination, Products}
@@ -12,8 +17,7 @@ defmodule BerrypodWeb.Shop.Collection do
{"name_desc", "Name: Z-A"}
]
@impl true
def mount(_params, _session, socket) do
def init(socket, _params, _uri) do
page = Pages.get_page("collection")
socket =
@@ -22,36 +26,52 @@ defmodule BerrypodWeb.Shop.Collection do
|> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured")
{:ok, socket}
{:noreply, socket}
end
@impl true
def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured"
page_num = Pagination.parse_page(params)
case load_collection(slug, sort, page_num) do
{:ok, title, category, pagination} ->
{:noreply,
socket
|> assign(:page_title, title)
|> assign(:page_description, collection_description(title))
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|> assign(:collection_title, title)
|> assign(:collection_slug, slug)
|> assign(:current_category, category)
|> assign(:current_sort, sort)
|> assign(:pagination, pagination)
|> assign(:products, pagination.items)}
socket =
socket
|> assign(:page_title, title)
|> assign(:page_description, collection_description(title))
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|> assign(:collection_title, title)
|> assign(:collection_slug, slug)
|> assign(:current_category, category)
|> assign(:current_sort, sort)
|> assign(:pagination, pagination)
|> assign(:products, pagination.items)
{:noreply, socket}
:not_found ->
{:noreply,
socket
|> put_flash(:error, "Collection not found")
|> push_navigate(to: ~p"/collections/all")}
socket =
socket
|> put_flash(:error, "Collection not found")
|> push_navigate(to: "/collections/all")
{:noreply, socket}
end
end
def handle_event("sort_changed", %{"sort" => sort}, socket) do
slug =
case socket.assigns.current_category do
nil -> "all"
:sale -> "sale"
category -> category.slug
end
{:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")}
end
def handle_event(_event, _params, _socket), do: :cont
defp load_collection("all", sort, page) do
pagination = Products.list_visible_products_paginated(sort: sort, page: page)
{:ok, "All Products", nil, pagination}
@@ -79,26 +99,7 @@ defmodule BerrypodWeb.Shop.Collection do
end
end
@impl true
def handle_event("sort_changed", %{"sort" => sort}, socket) do
slug =
case socket.assigns.current_category do
nil -> "all"
:sale -> "sale"
category -> category.slug
end
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
end
defp collection_description("All Products"), do: "Browse our full range of products."
defp collection_description("Sale"), do: "Browse our current sale items."
defp collection_description(title), do: "Browse our #{String.downcase(title)} collection."
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
end

View File

@@ -1,28 +1,37 @@
defmodule BerrypodWeb.Shop.Contact do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Contact do
@moduledoc """
Contact page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3]
alias Berrypod.{ContactNotifier, Orders}
alias Berrypod.Orders.OrderNotifier
alias Berrypod.Pages
alias BerrypodWeb.OrderLookupController
@impl true
def mount(_params, _session, socket) do
def init(socket, _params, _uri) do
page = Pages.get_page("contact")
{:ok,
socket
|> assign(:page_title, "Contact")
|> assign(
:page_description,
"Get in touch with us for any questions or help with your order."
)
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")
|> assign(:tracking_state, :idle)
|> assign(:page, page)}
socket =
socket
|> assign(:page_title, "Contact")
|> assign(
:page_description,
"Get in touch with us for any questions or help with your order."
)
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")
|> assign(:tracking_state, :idle)
|> assign(:page, page)
{:noreply, socket}
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
@impl true
def handle_event("lookup_orders", %{"email" => email}, socket) do
orders = Orders.list_orders_by_email(email)
@@ -31,7 +40,7 @@ defmodule BerrypodWeb.Shop.Contact do
:not_found
else
token = OrderLookupController.generate_token(email)
link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}"
link = BerrypodWeb.Endpoint.url() <> "/orders/verify/#{token}"
OrderNotifier.deliver_order_lookup(email, link)
:sent
end
@@ -39,14 +48,13 @@ defmodule BerrypodWeb.Shop.Contact do
{:noreply, assign(socket, :tracking_state, state)}
end
@impl true
def handle_event("send_contact", params, socket) do
case ContactNotifier.deliver_contact_message(params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Message sent! We'll get back to you soon.")
|> push_navigate(to: ~p"/contact")}
|> push_navigate(to: "/contact")}
{:error, :invalid_params} ->
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")}
@@ -56,15 +64,9 @@ defmodule BerrypodWeb.Shop.Contact do
end
end
@impl true
def handle_event("reset_tracking", _params, socket) do
{:noreply, assign(socket, :tracking_state, :idle)}
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
def handle_event(_event, _params, _socket), do: :cont
end

View File

@@ -1,20 +1,25 @@
defmodule BerrypodWeb.Shop.Content do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Content do
@moduledoc """
Content page handler for the unified Shop.Page LiveView.
Handles about, delivery, privacy, and terms pages.
"""
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.LegalPages
alias Berrypod.Pages
alias Berrypod.Theme.PreviewData
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
def init(socket, _params, _uri) do
# Content pages load in handle_params based on live_action
{:noreply, socket}
end
@impl true
def handle_params(_params, _uri, socket) do
slug = to_string(socket.assigns.live_action)
action = socket.assigns.live_action
slug = to_string(action)
page = Pages.get_page(slug)
{seo, content_blocks} = page_config(socket.assigns.live_action)
{seo, content_blocks} = page_config(action)
socket =
socket
@@ -25,19 +30,14 @@ defmodule BerrypodWeb.Shop.Content do
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
def handle_event(_event, _params, _socket), do: :cont
# Returns {seo_assigns, content_blocks} for each content page
defp page_config(:about) do
{
%{
page_title: "About",
page_description: "Your story goes here \u2013 this is sample content for the demo shop",
page_description: "Your story goes here this is sample content for the demo shop",
og_url: BerrypodWeb.Endpoint.url() <> "/about"
},
PreviewData.about_content()

View File

@@ -1,14 +1,17 @@
defmodule BerrypodWeb.Shop.CustomPage do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.CustomPage do
@moduledoc """
Custom (CMS) page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
def init(socket, _params, _uri) do
# Custom pages load in handle_params based on slug
{:noreply, socket}
end
@impl true
def handle_params(%{"slug" => slug}, _uri, socket) do
page = Pages.get_page(slug)
@@ -38,12 +41,7 @@ defmodule BerrypodWeb.Shop.CustomPage do
end
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
def handle_event(_event, _params, _socket), do: :cont
defp record_broken_url(path) do
prior_hits = Berrypod.Analytics.count_pageviews_for_path(path)

View File

@@ -1,10 +1,13 @@
defmodule BerrypodWeb.Shop.Home do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Home do
@moduledoc """
Home page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
@impl true
def mount(_params, _session, socket) do
def init(socket, _params, _uri) do
page = Pages.get_page("home")
extra = Pages.load_block_data(page.blocks, socket.assigns)
@@ -30,13 +33,12 @@ defmodule BerrypodWeb.Shop.Home do
|> assign(:page, page)
|> assign(extra)
{:ok, socket}
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
def handle_event(_event, _params, _socket), do: :cont
end

View File

@@ -1,12 +1,16 @@
defmodule BerrypodWeb.Shop.OrderDetail do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.OrderDetail do
@moduledoc """
Order detail page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [push_navigate: 2]
alias Berrypod.{Orders, Pages}
alias Berrypod.Products
alias Berrypod.Products.ProductImage
@impl true
def mount(_params, session, socket) do
def init(socket, _params, _uri, session) do
page = Pages.get_page("order_detail")
socket =
@@ -14,10 +18,9 @@ defmodule BerrypodWeb.Shop.OrderDetail do
|> assign(:lookup_email, session["order_lookup_email"])
|> assign(:page, page)
{:ok, socket}
{:noreply, socket}
end
@impl true
def handle_params(%{"order_number" => order_number}, _uri, socket) do
email = socket.assigns.lookup_email
@@ -43,20 +46,17 @@ defmodule BerrypodWeb.Shop.OrderDetail do
{id, %{thumb: thumb, slug: slug}}
end)
{:noreply,
socket
|> assign(:page_title, "Order #{order_number}")
|> assign(:order, order)
|> assign(:thumbnails, thumbnails)}
socket =
socket
|> assign(:page_title, "Order #{order_number}")
|> assign(:order, order)
|> assign(:thumbnails, thumbnails)
{:noreply, socket}
else
{:noreply, push_navigate(socket, to: ~p"/orders")}
{:noreply, push_navigate(socket, to: "/orders")}
end
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
def handle_event(_event, _params, _socket), do: :cont
end

View File

@@ -1,10 +1,13 @@
defmodule BerrypodWeb.Shop.Orders do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Orders do
@moduledoc """
Orders list page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
alias Berrypod.{Orders, Pages}
@impl true
def mount(_params, session, socket) do
def init(socket, _params, _uri, session) do
email = session["order_lookup_email"]
page = Pages.get_page("orders")
@@ -21,16 +24,12 @@ defmodule BerrypodWeb.Shop.Orders do
assign(socket, :orders, nil)
end
{:ok, socket}
{:noreply, socket}
end
@impl true
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
def handle_event(_event, _params, _socket), do: :cont
end

View File

@@ -1,16 +1,20 @@
defmodule BerrypodWeb.Shop.ProductShow do
use BerrypodWeb, :live_view
defmodule BerrypodWeb.Shop.Pages.Product do
@moduledoc """
Product detail page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 2, assign: 3]
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages}
alias Berrypod.Images.Optimizer
alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage}
@impl true
def mount(%{"id" => slug}, _session, socket) do
def init(socket, %{"id" => slug}, _uri) do
case Products.get_visible_product(slug) do
nil ->
{:ok, push_navigate(socket, to: ~p"/collections/all")}
{:noreply, push_navigate(socket, to: "/collections/all")}
product ->
all_images =
@@ -61,11 +65,10 @@ defmodule BerrypodWeb.Shop.ProductShow do
# Block data loaders (related_products, reviews) run after product is assigned
extra = Pages.load_block_data(page.blocks, socket.assigns)
{:ok, assign(socket, extra)}
{:noreply, assign(socket, extra)}
end
end
@impl true
def handle_params(params, _uri, socket) do
if socket.assigns[:product] do
{:noreply, apply_variant_params(params, socket)}
@@ -74,6 +77,50 @@ defmodule BerrypodWeb.Shop.ProductShow do
end
end
def handle_event("increment_quantity", _params, socket) do
quantity = min(socket.assigns.quantity + 1, 99)
{:noreply, assign(socket, :quantity, quantity)}
end
def handle_event("decrement_quantity", _params, socket) do
quantity = max(socket.assigns.quantity - 1, 1)
{:noreply, assign(socket, :quantity, quantity)}
end
def handle_event("add_to_cart", _params, socket) do
variant = socket.assigns.selected_variant
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
if socket.assigns[:analytics_visitor_hash] do
Analytics.track_event(
"add_to_cart",
Map.put(
BerrypodWeb.AnalyticsHook.attrs(socket),
:pathname,
"/products/#{socket.assigns.product.slug}"
)
)
end
socket =
socket
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|> assign(:quantity, 1)
|> assign(:cart_drawer_open, true)
|> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_event(_event, _params, _socket), do: :cont
# ── Variant selection logic ──────────────────────────────────────────
defp apply_variant_params(params, socket) do
%{option_types: option_types, variants: variants, product: product, all_images: all_images} =
socket.assigns
@@ -149,7 +196,7 @@ defmodule BerrypodWeb.Shop.ProductShow do
opt_type.values
|> Enum.map(fn value ->
params = Map.put(selected_options, opt_type.name, value.title)
{value.title, ~p"/products/#{slug}?#{params}"}
{value.title, "/products/#{slug}?#{URI.encode_query(params)}"}
end)
|> Map.new()
@@ -221,55 +268,7 @@ defmodule BerrypodWeb.Shop.ProductShow do
|> Enum.map(& &1.url)
end
@impl true
def handle_event("increment_quantity", _params, socket) do
quantity = min(socket.assigns.quantity + 1, 99)
{:noreply, assign(socket, :quantity, quantity)}
end
@impl true
def handle_event("decrement_quantity", _params, socket) do
quantity = max(socket.assigns.quantity - 1, 1)
{:noreply, assign(socket, :quantity, quantity)}
end
@impl true
def handle_event("add_to_cart", _params, socket) do
variant = socket.assigns.selected_variant
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
if socket.assigns[:analytics_visitor_hash] do
Analytics.track_event(
"add_to_cart",
Map.put(
BerrypodWeb.AnalyticsHook.attrs(socket),
:pathname,
"/products/#{socket.assigns.product.slug}"
)
)
end
socket =
socket
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|> assign(:quantity, 1)
|> assign(:cart_drawer_open, true)
|> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
# ── JSON-LD and meta helpers ─────────────────────────────────────────
defp product_json_ld(product, url, image, base) do
category_slug =

View File

@@ -0,0 +1,39 @@
defmodule BerrypodWeb.Shop.Pages.Search do
@moduledoc """
Search page handler for the unified Shop.Page LiveView.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [push_patch: 2]
alias Berrypod.{Pages, Search}
def init(socket, _params, _uri) do
page = Pages.get_page("search")
socket =
socket
|> assign(:page_title, "Search")
|> assign(:page, page)
{:noreply, socket}
end
def handle_params(params, _uri, socket) do
query = params["q"] || ""
results = if query != "", do: Search.search(query), else: []
socket =
socket
|> assign(:search_page_query, query)
|> assign(:search_page_results, results)
{:noreply, socket}
end
def handle_event("search_submit", %{"q" => query}, socket) do
{:noreply, push_patch(socket, to: "/search?q=#{query}")}
end
def handle_event(_event, _params, _socket), do: :cont
end

View File

@@ -1,34 +0,0 @@
defmodule BerrypodWeb.Shop.Search do
use BerrypodWeb, :live_view
alias Berrypod.{Pages, Search}
@impl true
def mount(_params, _session, socket) do
page = Pages.get_page("search")
{:ok, socket |> assign(:page_title, "Search") |> assign(:page, page)}
end
@impl true
def handle_params(params, _uri, socket) do
query = params["q"] || ""
results = if query != "", do: Search.search(query), else: []
{:noreply,
socket
|> assign(:search_page_query, query)
|> assign(:search_page_results, results)}
end
@impl true
def handle_event("search_submit", %{"q" => query}, socket) do
{:noreply, push_patch(socket, to: ~p"/search?q=#{query}")}
end
@impl true
def render(assigns) do
~H"""
<BerrypodWeb.PageRenderer.render_page {assigns} />
"""
end
end