All checks were successful
deploy / deploy (push) Successful in 1m24s
New block types: spacer, divider, button/CTA, video embed (YouTube, Vimeo with privacy-enhanced embeds, fallback for unknown URLs). Page templates (blank, content, landing) shown when creating custom pages. Duplicate page action on admin index with slug deduplication. Fix block picker on shop edit sidebar being cut off on mobile by accounting for bottom nav and making the grid scrollable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1184 lines
41 KiB
Elixir
1184 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
|
|
~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"] || ""
|
|
{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}× {item.product_name}
|
|
<%= if item.variant_title && item.variant_title != "" do %>
|
|
<span class="order-summary-variant">· {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">→</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">← 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 “{assigns[:search_page_query]}”</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
|