berrypod/lib/berrypod_web/page_renderer.ex
jamey ad2e6d1e6d add newsletter and email campaigns
Subscribers with double opt-in confirmation, campaign composer with
draft/scheduled/sent lifecycle, admin dashboard with overview stats,
CSV export, and shop signup form wired into page builder blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:25:28 +00:00

1202 lines
41 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="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">
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_"
/>
<%!-- 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"], settings["image_url"])
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
~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"] || ""
{image_src, image_alt} = resolve_content_image(settings)
assigns =
assigns
|> assign(:image_src, image_src)
|> 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={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
# ── 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">
<a href={@raw_url} target="_blank" rel="noopener noreferrer">
{if @caption != "", do: @caption, else: "Watch video"}
</a>
</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 URL, falling back to a legacy URL string
defp resolve_block_image_url(image_id, fallback_url) do
case resolve_image(image_id) do
{url, _alt} -> url
nil -> fallback_url || ""
end
end
# Resolves image_id for content_body blocks, returning {src, alt}
defp resolve_content_image(settings) do
case resolve_image(settings["image_id"]) do
{src, alt} -> {src, alt}
nil -> {settings["image_src"], settings["image_alt"] || ""}
end
end
defp resolve_image(nil), do: nil
defp resolve_image(""), do: nil
defp resolve_image(image_id) do
case Berrypod.Media.get_image(image_id) do
nil ->
nil
image ->
url =
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
# Pick the largest variant that was actually generated
width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> List.last()
"/image_cache/#{image.id}-#{width || 400}.webp"
end
{url, image.alt || image.filename}
end
end
@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