berrypod/lib/berrypod_web/page_renderer.ex
jamey a039c8d53c
All checks were successful
deploy / deploy (push) Successful in 6m49s
add live page editor sidebar with collapsible UI
Admins can now edit pages directly on the live shop by clicking the
pencil icon in the header. A sidebar slides in with block management
controls (add, remove, reorder, edit settings, save, reset, done).

Key features:
- PageEditorHook on_mount with handle_params/event/info hooks
- BlockEditor pure functions extracted from admin editor
- Shared BlockEditorComponents with event_prefix namespacing
- Collapsible sidebar: X closes it, header pencil reopens it
- Backdrop overlay dismisses sidebar on tap
- Conditional admin.css loading for logged-in users
- content_body block now portable (textarea setting + rich text fallback)

13 integration tests, 26 unit tests, 1370 total passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:22:35 +00:00

1010 lines
35 KiB
Elixir

defmodule BerrypodWeb.PageRenderer do
@moduledoc """
Generic page renderer for the page builder.
One `render_page/1` renders ALL pages — no page-specific render functions.
Each page is a flat list of blocks rendered in order. Block components
dispatch to existing shop components.
"""
use Phoenix.Component
use BerrypodWeb.ShopComponents
use Phoenix.VerifiedRoutes,
endpoint: BerrypodWeb.Endpoint,
router: BerrypodWeb.Router,
statics: BerrypodWeb.static_paths()
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.CoreComponents, only: [icon: 1]
alias Berrypod.Cart
# ── Public API ──────────────────────────────────────────────────
@doc """
Renders a full page from its block definition.
Expects `@page` (with `:slug` and `:blocks`) plus all the standard
layout assigns (theme_settings, cart_items, etc.).
When `@editing` is true and `@editing_blocks` is set (admin using the
live page editor), wraps the page in a sidebar + content layout.
"""
def render_page(assigns) do
if assigns[:editing] && assigns[:editing_blocks] do
render_page_with_editor(assigns)
else
render_page_normal(assigns)
end
end
defp render_page_normal(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<main id="main-content" class={page_main_class(@page.slug)}>
<div :for={block <- @page.blocks} :key={block["id"]} data-block-type={block["type"]}>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</main>
</.shop_layout>
"""
end
defp render_page_with_editor(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
<div
id="page-editor-live"
class="page-editor-live"
phx-hook="DirtyGuard"
data-dirty={to_string(@editor_dirty)}
data-sidebar-open={to_string(@editor_sidebar_open)}
>
<aside class="page-editor-sidebar" aria-label="Page editor">
<div class="page-editor-sidebar-header">
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
<div class="page-editor-sidebar-actions">
<button
phx-click="editor_save"
class={[
"admin-btn admin-btn-sm admin-btn-primary",
!@editor_dirty && "opacity-50"
]}
disabled={!@editor_dirty}
>
Save
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset
</button>
<button phx-click="editor_done" class="admin-btn admin-btn-sm admin-btn-ghost">
Done
</button>
<button
phx-click="editor_toggle_sidebar"
class="admin-btn admin-btn-sm admin-btn-ghost"
aria-label="Close sidebar"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
</div>
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Unsaved changes indicator --%>
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
Unsaved changes
</p>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
block={block}
idx={idx}
total={length(@editing_blocks)}
expanded={@editor_expanded}
event_prefix="editor_"
/>
<div :if={@editing_blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@editor_show_picker}
allowed_blocks={@editor_allowed_blocks}
filter={@editor_picker_filter}
event_prefix="editor_"
/>
</aside>
<%!-- Backdrop: tapping the page dismisses the sidebar --%>
<div
:if={@editor_sidebar_open}
class="page-editor-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="page-editor-content">
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<main id="main-content" class={page_main_class(@page.slug)}>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</main>
</.shop_layout>
</div>
</div>
"""
end
# ── Block dispatch ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "hero"}} = assigns) do
settings = assigns.block["settings"] || %{}
variant = hero_variant(settings["variant"])
# Page-context assigns (e.g. error_title, error_code) override block settings
assigns =
assigns
|> assign(:title, assigns[:error_title] || settings["title"] || "")
|> assign(:description, assigns[:error_description] || settings["description"] || "")
|> assign(:variant, variant)
|> assign(:background, hero_background(settings["variant"]))
|> assign(:pre_title, assigns[:error_code] || settings["pre_title"])
|> assign(:cta_text, settings["cta_text"])
|> assign(:cta_href, settings["cta_href"])
|> assign(:secondary_cta_text, settings["secondary_cta_text"])
|> assign(:secondary_cta_href, settings["secondary_cta_href"])
~H"""
<.hero_section
title={@title}
description={@description}
variant={@variant}
background={@background}
pre_title={@pre_title}
cta_text={@cta_text}
cta_href={@cta_href}
secondary_cta_text={@secondary_cta_text}
secondary_cta_href={@secondary_cta_href}
mode={@mode}
/>
"""
end
defp render_block(%{block: %{"type" => "category_nav"}} = assigns) do
~H"""
<.category_nav categories={assigns[:categories] || []} mode={@mode} />
"""
end
defp render_block(%{block: %{"type" => "featured_products"}} = assigns) do
settings = assigns.block["settings"] || %{}
assigns =
assigns
|> assign(:section_title, settings["title"] || "Featured products")
|> assign(:layout, settings["layout"] || "section")
|> assign(:card_variant, card_variant(settings["card_variant"]))
|> assign(:columns, grid_columns(settings["columns"]))
~H"""
<%= if @layout == "grid" do %>
<.product_grid columns={@columns} theme_settings={@theme_settings}>
<%= for product <- assigns[:products] || [] do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={@card_variant}
/>
<% end %>
</.product_grid>
<% else %>
<.featured_products_section
title={@section_title}
products={assigns[:products] || []}
theme_settings={@theme_settings}
mode={@mode}
/>
<% end %>
"""
end
defp render_block(%{block: %{"type" => "image_text"}} = assigns) do
settings = assigns.block["settings"] || %{}
assigns =
assigns
|> assign(:section_title, settings["title"] || "")
|> assign(:section_description, settings["description"] || "")
|> assign(:image_url, settings["image_url"] || "")
|> assign(:link_text, settings["link_text"])
|> assign(:link_href, settings["link_href"])
~H"""
<.image_text_section
title={@section_title}
description={@section_description}
image_url={@image_url}
link_text={@link_text}
link_href={@link_href}
mode={@mode}
/>
"""
end
defp render_block(%{block: %{"type" => "newsletter_card"}} = assigns) do
~H"<.newsletter_card />"
end
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
~H"<.social_links_card />"
end
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do
settings = assigns.block["settings"] || %{}
items =
(settings["items"] || [])
|> Enum.map(fn item ->
%{label: item["label"] || item[:label], value: item["value"] || item[:value]}
end)
assigns =
assigns
|> assign(:card_title, settings["title"] || "")
|> assign(:items, items)
~H"""
<.info_card title={@card_title} items={@items} />
"""
end
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
~H"""
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
"""
end
defp render_block(%{block: %{"type" => "reviews_section"}} = assigns) do
~H"""
<.reviews_section
:if={@theme_settings.pdp_reviews}
reviews={assigns[:reviews] || []}
average_rating={assigns[:average_rating] || 5}
total_count={assigns[:total_count]}
/>
"""
end
# ── PDP blocks ──────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "breadcrumb"}} = assigns) do
~H"""
<.breadcrumb
:if={assigns[:product]}
items={breadcrumb_items(assigns[:product])}
mode={@mode}
/>
"""
end
defp render_block(%{block: %{"type" => "product_hero"}} = assigns) do
~H"""
<%= if assigns[:product] do %>
<div class="pdp-grid">
<.product_gallery images={assigns[:gallery_images] || []} product_name={@product.title} />
<div>
<.product_info product={@product} display_price={assigns[:display_price]} />
<form action="/cart/add" method="post" phx-submit="add_to_cart">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input
type="hidden"
name="variant_id"
value={assigns[:selected_variant] && assigns[:selected_variant].id}
/>
<%= for option_type <- assigns[:option_types] || [] do %>
<.variant_selector
option_type={option_type}
selected={(assigns[:selected_options] || %{})[option_type.name]}
available={(assigns[:available_options] || %{})[option_type.name] || []}
mode={@mode}
option_urls={(assigns[:option_urls] || %{})[option_type.name] || %{}}
/>
<% end %>
<div
:if={(assigns[:option_types] || []) == []}
class="pdp-variant-fallback"
>
One size
</div>
<.quantity_selector quantity={assigns[:quantity] || 1} in_stock={@product.in_stock} />
<.add_to_cart_button mode={@mode} />
</form>
</div>
</div>
<% end %>
"""
end
defp render_block(%{block: %{"type" => "product_details"}} = assigns) do
~H"""
<.product_details :if={assigns[:product]} product={@product} />
"""
end
defp render_block(%{block: %{"type" => "related_products"}} = assigns) do
~H"""
<.related_products_section
:if={@theme_settings.pdp_related_products && assigns[:related_products] != []}
products={assigns[:related_products] || []}
theme_settings={@theme_settings}
mode={@mode}
/>
"""
end
# ── Collection blocks ───────────────────────────────────────────
defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do
~H"""
<.collection_header
title={assigns[:collection_title] || "All Products"}
product_count={length(assigns[:products] || [])}
/>
"""
end
defp render_block(%{block: %{"type" => "filter_bar"}} = assigns) do
current_slug =
case assigns[:current_category] do
:sale -> "sale"
nil -> nil
cat -> cat.slug
end
assigns =
assigns
|> assign(:current_slug, current_slug)
|> assign(:current_sort, assigns[:current_sort] || "featured")
|> assign(:sort_options, assigns[:sort_options] || [])
~H"""
<div class="page-container">
<div class="filter-bar">
<nav
aria-label="Collection filters"
id="collection-filters"
phx-hook="CollectionFilters"
class="collection-filters"
>
<ul class="collection-filter-pills">
<li>
<.link
navigate={collection_path("all", @current_sort)}
aria-current={@current_slug == nil && "page"}
class={["collection-filter-pill", @current_slug == nil && "active"]}
>
All
</.link>
</li>
<li>
<.link
navigate={collection_path("sale", @current_sort)}
aria-current={@current_slug == "sale" && "page"}
class={["collection-filter-pill", @current_slug == "sale" && "active"]}
>
Sale
</.link>
</li>
<%= for category <- assigns[:categories] || [] do %>
<li>
<.link
navigate={collection_path(category.slug, @current_sort)}
aria-current={@current_slug == category.slug && "page"}
class={["collection-filter-pill", @current_slug == category.slug && "active"]}
>
{category.name}
</.link>
</li>
<% end %>
</ul>
</nav>
<form
action={~p"/collections/#{@current_slug || "all"}"}
method="get"
phx-change="sort_changed"
>
<.shop_select
name="sort"
options={@sort_options}
selected={@current_sort}
aria-label="Sort products"
/>
<noscript>
<button type="submit" class="themed-button collection-sort-submit">Sort</button>
</noscript>
</form>
</div>
</div>
"""
end
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
show_category = assigns[:current_category] in [nil, :sale]
assigns = assign(assigns, :show_category, show_category)
~H"""
<div class="page-container">
<.product_grid theme_settings={@theme_settings}>
<%= for product <- assigns[:products] || [] do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:default}
show_category={@show_category}
/>
<% end %>
</.product_grid>
<%= if (assigns[:products] || []) == [] do %>
<div class="collection-empty">
<p>No products found in this collection.</p>
<.link navigate={~p"/collections/all"} class="collection-empty-link">
View all products
</.link>
</div>
<% end %>
</div>
"""
end
# ── Cart blocks ─────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "cart_items"}} = assigns) do
~H"""
<.page_title text="Your basket" />
<%= if @cart_items == [] do %>
<.cart_empty_state mode={@mode} />
<% else %>
<ul role="list" aria-label="Cart items" class="cart-page-list">
<%= for item <- @cart_items do %>
<li>
<.shop_card class="cart-page-card">
<.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} />
</.shop_card>
</li>
<% end %>
</ul>
<% end %>
"""
end
defp render_block(%{block: %{"type" => "order_summary"}} = assigns) do
~H"""
<%= if @cart_items != [] do %>
<.order_summary
subtotal={assigns[:cart_page_subtotal] || 0}
shipping_estimate={assigns[:shipping_estimate]}
country_code={assigns[:country_code] || "GB"}
available_countries={assigns[:available_countries] || []}
mode={@mode}
/>
<% end %>
"""
end
# ── Contact blocks ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "contact_form"}} = assigns) do
settings = assigns.block["settings"] || %{}
assigns = assign(assigns, :email, settings["email"])
~H"""
<.contact_form email={@email} />
"""
end
defp render_block(%{block: %{"type" => "order_tracking_card"}} = assigns) do
~H"""
<.order_tracking_card tracking_state={assigns[:tracking_state] || :idle} />
"""
end
# ── Content blocks ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{}
content = settings["content"] || ""
assigns =
assigns
|> assign(:image_src, settings["image_src"])
|> assign(:image_alt, settings["image_alt"] || "")
|> assign(:content, content)
~H"""
<div class="content-body">
<%= if @image_src && @image_src != "" do %>
<div class="content-image">
<.responsive_image
src={@image_src}
source_width={1200}
alt={@image_alt}
sizes="(max-width: 800px) 100vw, 800px"
class="content-hero-image"
/>
</div>
<% end %>
<%= if @content != "" do %>
<%!-- Inline content from block settings — split on blank lines for paragraphs --%>
<div class="rich-text">
<p :for={para <- String.split(@content, ~r/\n{2,}/, trim: true)} class="rich-text-paragraph">
{para}
</p>
</div>
<% else %>
<%!-- Structured rich text from LiveView (content pages) --%>
<.rich_text blocks={assigns[:content_blocks] || []} />
<% end %>
</div>
"""
end
# ── Checkout success ────────────────────────────────────────────
defp render_block(%{block: %{"type" => "checkout_result"}} = assigns) do
~H"""
<%= if assigns[:order] && assigns[:order].payment_status == "paid" do %>
<div class="checkout-header">
<div class="checkout-icon">
<svg
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h1 class="checkout-heading">Thank you for your order</h1>
<p class="checkout-meta">Order <strong>{assigns[:order].order_number}</strong></p>
<%= if assigns[:order].customer_email do %>
<p class="checkout-meta">
A confirmation will be sent to <strong>{assigns[:order].customer_email}</strong>
</p>
<% end %>
</div>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">Order details</h2>
<ul class="checkout-items">
<%= for item <- assigns[:order].items do %>
<li class="checkout-item">
<div>
<p class="checkout-item-name">{item.product_name}</p>
<%= if item.variant_title do %>
<p class="checkout-item-detail">{item.variant_title}</p>
<% end %>
<p class="checkout-item-detail">Qty: {item.quantity}</p>
</div>
<span class="checkout-item-price">
{Cart.format_price(item.unit_price * item.quantity)}
</span>
</li>
<% end %>
</ul>
<div class="checkout-total-border">
<div class="checkout-total">
<span class="checkout-total-label">Total</span>
<span class="checkout-total-amount">{Cart.format_price(assigns[:order].total)}</span>
</div>
</div>
</.shop_card>
<%= if assigns[:order].shipping_address != %{} do %>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">Shipping to</h2>
<div class="checkout-shipping-address">
<p>{assigns[:order].shipping_address["name"]}</p>
<p>{assigns[:order].shipping_address["line1"]}</p>
<%= if assigns[:order].shipping_address["line2"] do %>
<p>{assigns[:order].shipping_address["line2"]}</p>
<% end %>
<p>
{assigns[:order].shipping_address["city"]}, {assigns[:order].shipping_address[
"postal_code"
]}
</p>
<p>{assigns[:order].shipping_address["country"]}</p>
</div>
</.shop_card>
<% end %>
<div class="checkout-actions">
<.shop_link_button href="/collections/all" class="checkout-cta">
Continue shopping
</.shop_link_button>
</div>
<% else %>
<div class="checkout-header">
<div class="checkout-pending-icon">
<span class="checkout-pending-spinner">
<svg
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</span>
</div>
<h1 class="checkout-heading">Processing your payment</h1>
<p class="checkout-pending-text">
Please wait while we confirm your payment. This usually takes a few seconds.
</p>
<p class="checkout-pending-hint">
If this page doesn't update, please <.link navigate="/contact" class="checkout-contact-link">contact us</.link>.
</p>
</div>
<% end %>
"""
end
# ── Orders ──────────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "order_card"}} = assigns) do
~H"""
<div class="orders-header">
<h1 class="orders-page-title">Your orders</h1>
<%= if assigns[:lookup_email] do %>
<p class="orders-email-label">
Orders for <strong>{assigns[:lookup_email]}</strong>
</p>
<% end %>
</div>
<%= cond do %>
<% is_nil(assigns[:orders]) -> %>
<div class="orders-empty">
<p>This link has expired or is invalid.</p>
<p class="orders-empty-hint">
Head back to the <.link navigate="/contact">contact page</.link> to request a new one.
</p>
</div>
<% assigns[:orders] == [] -> %>
<div class="orders-empty">
<p>No orders found for that email address.</p>
<p class="orders-empty-hint">
If something doesn't look right, <.link navigate="/contact">get in touch</.link>.
</p>
</div>
<% true -> %>
<div class="orders-list">
<%= for order <- assigns[:orders] do %>
<.link navigate={"/orders/#{order.order_number}"} class="order-summary-card">
<div class="order-summary-top">
<div>
<p class="order-summary-number">{order.order_number}</p>
<p class="order-summary-date">
{Calendar.strftime(order.inserted_at, "%-d %B %Y")}
</p>
</div>
<span class={"order-status-badge order-status-badge--#{order.fulfilment_status}"}>
{format_order_status(order.fulfilment_status)}
</span>
</div>
<ul class="order-summary-items">
<%= for item <- Enum.take(order.items, 2) do %>
<li class="order-summary-item">
{item.quantity}&times; {item.product_name}
<%= if item.variant_title && item.variant_title != "" do %>
<span class="order-summary-variant">&middot; {item.variant_title}</span>
<% end %>
</li>
<% end %>
<%= if length(order.items) > 2 do %>
<li class="order-summary-more">+{length(order.items) - 2} more</li>
<% end %>
</ul>
<div class="order-summary-footer">
<span class="order-summary-total">{Cart.format_price(order.total)}</span>
<span class="order-summary-arrow">&rarr;</span>
</div>
</.link>
<% end %>
</div>
<% end %>
"""
end
# ── Order detail ────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "order_detail_card"}} = assigns) do
~H"""
<%= if assigns[:order] do %>
<div class="order-detail-header">
<.link navigate="/orders" class="order-detail-back">&larr; Back to orders</.link>
<h1 class="checkout-heading" style="margin-top: 1.5rem;">{assigns[:order].order_number}</h1>
<p class="checkout-meta">{Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}</p>
<span class={"order-status-badge order-status-badge--#{assigns[:order].fulfilment_status} order-status-badge--lg"}>
{format_order_status(assigns[:order].fulfilment_status)}
</span>
</div>
<%= if assigns[:order].tracking_number || assigns[:order].tracking_url do %>
<.shop_card class="order-detail-tracking-card">
<div class="order-detail-tracking">
<svg
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 18.75a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 0 1-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 0 0-3.213-9.193 2.056 2.056 0 0 0-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 0 0-10.026 0 1.106 1.106 0 0 0-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"
/>
</svg>
<div>
<p class="order-detail-tracking-label">Shipment tracking</p>
<%= if assigns[:order].tracking_number do %>
<p class="order-detail-tracking-number">{assigns[:order].tracking_number}</p>
<% end %>
</div>
<%= if assigns[:order].tracking_url do %>
<a
href={assigns[:order].tracking_url}
target="_blank"
rel="noopener noreferrer"
class="order-detail-tracking-btn themed-button"
>
Track parcel
</a>
<% end %>
</div>
</.shop_card>
<% end %>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">Items ordered</h2>
<ul class="checkout-items">
<%= for item <- assigns[:order].items do %>
<% info = (assigns[:thumbnails] || %{})[item.variant_id] %>
<li class="checkout-item">
<%= if info && info.thumb do %>
<img src={info.thumb} alt={item.product_name} class="checkout-item-thumb" />
<% end %>
<div>
<%= if info && info.slug do %>
<.link
navigate={"/products/#{info.slug}"}
class="checkout-item-name checkout-item-link"
>
{item.product_name}
</.link>
<% else %>
<p class="checkout-item-name">{item.product_name}</p>
<% end %>
<%= if item.variant_title && item.variant_title != "" do %>
<p class="checkout-item-detail">{item.variant_title}</p>
<% end %>
<p class="checkout-item-detail">Qty: {item.quantity}</p>
</div>
<span class="checkout-item-price">
{Cart.format_price(item.unit_price * item.quantity)}
</span>
</li>
<% end %>
</ul>
<div class="checkout-total-border">
<div class="checkout-total">
<span class="checkout-total-label">Total</span>
<span class="checkout-total-amount">{Cart.format_price(assigns[:order].total)}</span>
</div>
</div>
</.shop_card>
<%= if assigns[:order].shipping_address != %{} do %>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">Shipping to</h2>
<div class="checkout-shipping-address">
<p>{assigns[:order].shipping_address["name"]}</p>
<p>{assigns[:order].shipping_address["line1"]}</p>
<%= if assigns[:order].shipping_address["line2"] do %>
<p>{assigns[:order].shipping_address["line2"]}</p>
<% end %>
<p>
{assigns[:order].shipping_address["city"]}, {assigns[:order].shipping_address[
"postal_code"
]}
</p>
<p>{assigns[:order].shipping_address["country"]}</p>
</div>
</.shop_card>
<% end %>
<div class="checkout-actions">
<.shop_link_button href="/collections/all">Continue shopping</.shop_link_button>
</div>
<% end %>
"""
end
# ── Search ──────────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "search_results"}} = assigns) do
~H"""
<.page_title text="Search" />
<form action="/search" method="get" phx-submit="search_submit" class="search-page-form">
<input
type="search"
name="q"
value={assigns[:search_page_query] || ""}
placeholder="Search products..."
class="themed-input"
/>
<button type="submit" class="themed-button">Search</button>
</form>
<%= if (assigns[:search_page_results] || []) != [] do %>
<p class="search-page-count">
{length(assigns[:search_page_results])} {if length(assigns[:search_page_results]) == 1,
do: "result",
else: "results"} for "{assigns[:search_page_query]}"
</p>
<.product_grid theme_settings={@theme_settings}>
<%= for product <- assigns[:search_page_results] do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:default}
/>
<% end %>
</.product_grid>
<% else %>
<%= if (assigns[:search_page_query] || "") != "" do %>
<div class="collection-empty">
<p>No products found for &ldquo;{assigns[:search_page_query]}&rdquo;</p>
<.link navigate="/collections/all" class="collection-empty-link">Browse all products</.link>
</div>
<% end %>
<% end %>
"""
end
# ── Fallback ────────────────────────────────────────────────────
defp render_block(assigns) do
~H"""
<%!-- Unknown block type: {assigns.block["type"]} --%>
"""
end
# ── Helpers ─────────────────────────────────────────────────────
@doc "Returns a CSS class for the page's `<main>` element."
def page_main_class("contact"), do: "page-container contact-main"
def page_main_class("cart"), do: "page-container"
def page_main_class("checkout_success"), do: "page-container checkout-main"
def page_main_class("orders"), do: "page-container orders-main"
def page_main_class("order_detail"), do: "page-container order-detail-main"
def page_main_class("error"), do: "error-main"
def page_main_class("collection"), do: nil
def page_main_class("pdp"), do: "page-container"
def page_main_class("search"), do: "page-container"
def page_main_class("about"), do: "content-page"
def page_main_class("delivery"), do: "content-page"
def page_main_class("privacy"), do: "content-page"
def page_main_class("terms"), do: "content-page"
def page_main_class(_), do: nil
# Extracts the assigns that individual blocks need (everything except page metadata).
# Preserves __changed__ so assign/3 works inside render_block functions.
defp block_assigns(assigns) do
Map.drop(assigns, [:page, :block_assigns])
end
defp hero_variant("page"), do: :page
defp hero_variant("sunken"), do: :default
defp hero_variant("error"), do: :error
defp hero_variant(_), do: :default
defp hero_background("sunken"), do: :sunken
defp hero_background(_), do: :base
defp card_variant("minimal"), do: :minimal
defp card_variant("compact"), do: :compact
defp card_variant("default"), do: :default
defp card_variant(_), do: :featured
defp grid_columns("fixed-4"), do: :fixed_4
defp grid_columns(_), do: nil
defp breadcrumb_items(%{category: cat, title: title}) when not is_nil(cat) do
slug = cat |> String.downcase() |> String.replace(" ", "-")
[
%{label: cat, page: "collection", href: "/collections/#{slug}"},
%{label: title, current: true}
]
end
defp breadcrumb_items(%{title: title}) do
[%{label: title, current: true}]
end
defp breadcrumb_items(_), do: []
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
def format_order_status("unfulfilled"), do: "Being prepared"
def format_order_status("submitted"), do: "Sent to printer"
def format_order_status("processing"), do: "In production"
def format_order_status("shipped"), do: "On its way"
def format_order_status("delivered"), do: "Delivered"
def format_order_status("failed"), do: "Issue — contact us"
def format_order_status("cancelled"), do: "Cancelled"
def format_order_status(status), do: status
end