berrypod/lib/berrypod_web/page_renderer.ex
jamey 67a26eb6b4
All checks were successful
deploy / deploy (push) Successful in 1m26s
add contextual prompts for skipped setup steps
Disable checkout when Stripe isn't connected (cart drawer, cart page,
and early guard in checkout controller to prevent orphaned orders).
Show amber warning on order detail when email isn't configured.
Fix pre-existing missing vertical spacing between page blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:02:49 +00:00

1217 lines
42 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, external_link: 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="EditorKeyboard"
data-dirty={to_string(@editor_dirty)}
data-event-prefix="editor_"
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_undo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_history == [] && "opacity-30"
]}
disabled={@editor_history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="editor_redo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_future == [] && "opacity-30"
]}
disabled={@editor_future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<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"
data-confirm={@editor_dirty && "You have unsaved changes. Leave without saving?"}
>
Done
</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_"
/>
<%!-- Image picker modal --%>
<.image_picker
:if={@editor_image_picker_block_id}
images={@editor_image_picker_images}
search={@editor_image_picker_search}
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"] || %{}
image_url = resolve_block_image_url(settings["image_id"])
assigns =
assigns
|> assign(:section_title, settings["title"] || "")
|> assign(:section_description, settings["description"] || "")
|> assign(:image_url, 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
settings = assigns.block["settings"] || %{}
assigns =
assigns
|> assign(:title, settings["title"] || "Newsletter")
|> assign(:description, settings["description"] || "")
|> assign(:button_text, settings["button_text"] || "Subscribe")
|> assign(:newsletter_state, assigns[:newsletter_state] || :idle)
|> assign(:newsletter_enabled, assigns[:newsletter_enabled] || false)
~H"""
<.newsletter_card
title={@title}
description={@description}
button_text={@button_text}
newsletter_state={@newsletter_state}
newsletter_enabled={@newsletter_enabled}
/>
"""
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
count =
if assigns[:pagination] do
assigns.pagination.total_count
else
length(assigns[:products] || [])
end
assigns = assign(assigns, :product_count, count)
~H"""
<.collection_header
title={assigns[:collection_title] || "All Products"}
product_count={@product_count}
/>
"""
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]
collection_slug = assigns[:collection_slug] || "all"
current_sort = assigns[:current_sort] || "featured"
sort_params = if current_sort != "featured", do: %{"sort" => current_sort}, else: %{}
assigns =
assigns
|> assign(:show_category, show_category)
|> assign(:collection_slug, collection_slug)
|> assign(:sort_params, sort_params)
~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>
<.shop_pagination
:if={assigns[:pagination] && assigns[:pagination].total_pages > 1}
page={assigns[:pagination]}
base_path={~p"/collections/#{@collection_slug}"}
params={@sort_params}
/>
<%= 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] || []}
stripe_connected={assigns[:stripe_connected] || false}
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"] || ""
{image_src, source_width, image_alt} = resolve_content_image(settings)
assigns =
assigns
|> assign(:image_src, image_src)
|> assign(:image_source_width, source_width || 1200)
|> assign(:image_alt, 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={@image_source_width}
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 %>
<.external_link
href={assigns[:order].tracking_url}
class="order-detail-tracking-btn themed-button"
>
Track parcel
</.external_link>
<% 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
# ── Utility blocks ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "spacer"}} = assigns) do
size = get_in(assigns.block, ["settings", "size"]) || "medium"
assigns = assign(assigns, :size, size)
~H"""
<div class="block-spacer" data-size={@size} aria-hidden="true"></div>
"""
end
defp render_block(%{block: %{"type" => "divider"}} = assigns) do
style = get_in(assigns.block, ["settings", "style"]) || "line"
assigns = assign(assigns, :style, style)
~H"""
<hr class="block-divider" data-style={@style} />
"""
end
defp render_block(%{block: %{"type" => "button"}} = assigns) do
settings = assigns.block["settings"] || %{}
assigns =
assigns
|> assign(:text, settings["text"] || "Learn more")
|> assign(:href, settings["href"] || "/")
|> assign(:btn_style, settings["style"] || "primary")
|> assign(:alignment, settings["alignment"] || "centre")
~H"""
<div class="block-button" data-align={@alignment}>
<.link
navigate={@href}
class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"}
>
{@text}
</.link>
</div>
"""
end
defp render_block(%{block: %{"type" => "video_embed"}} = assigns) do
settings = assigns.block["settings"] || %{}
url = settings["url"] || ""
caption = settings["caption"] || ""
aspect_ratio = settings["aspect_ratio"] || "16:9"
{provider, embed_url} = parse_video_url(url)
assigns =
assigns
|> assign(:embed_url, embed_url)
|> assign(:provider, provider)
|> assign(:caption, caption)
|> assign(:aspect_ratio, aspect_ratio)
|> assign(:raw_url, url)
~H"""
<div class="page-container">
<div :if={@provider != :unknown} class="video-embed" data-ratio={@aspect_ratio}>
<iframe
src={@embed_url}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
title={if @caption != "", do: @caption, else: "Embedded video"}
>
</iframe>
</div>
<p :if={@provider == :unknown && @raw_url != ""} class="video-embed-fallback">
<.external_link href={@raw_url}>
{if @caption != "", do: @caption, else: "Watch video"}
</.external_link>
</p>
<p :if={@caption != "" && @provider != :unknown} class="video-embed-caption">{@caption}</p>
</div>
"""
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}"
# Resolves an image_id to a full URL for blocks that need a single URL (e.g. background-image).
defp resolve_block_image_url(image_id) do
case resolve_image(image_id) do
nil ->
""
image ->
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> List.last()
"/image_cache/#{image.id}-#{width || 400}.webp"
end
end
end
# Resolves image_id for content_body blocks, returning {base_path, source_width, alt}.
# base_path has no width/extension suffix — responsive_image adds those.
defp resolve_content_image(settings) do
case resolve_image(settings["image_id"]) do
nil ->
{nil, nil, ""}
image ->
{"/image_cache/#{image.id}", image.source_width, image.alt || image.filename}
end
end
defp resolve_image(nil), do: nil
defp resolve_image(""), do: nil
defp resolve_image(image_id), do: Berrypod.Media.get_image(image_id)
@youtube_re ~r/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
@vimeo_re ~r/vimeo\.com\/(?:video\/)?(\d+)/
defp parse_video_url(url) when is_binary(url) do
cond do
match = Regex.run(@youtube_re, url) ->
{:youtube, "https://www.youtube-nocookie.com/embed/#{Enum.at(match, 1)}"}
match = Regex.run(@vimeo_re, url) ->
{:vimeo, "https://player.vimeo.com/video/#{Enum.at(match, 1)}?dnt=1"}
true ->
{:unknown, nil}
end
end
defp parse_video_url(_), do: {:unknown, nil}
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