All checks were successful
deploy / deploy (push) Successful in 1m16s
Two bugs fixed: 1. Page Settings section wasn't appearing for system pages because Defaults.for_slug didn't return all required fields (type, meta_description, published, etc). Also changed page_renderer to use bracket notation for safer field access. 2. Blocks weren't loading when navigating directly to ?edit=page because the PageEditorHook's handle_params ran before Shop.Page assigned @page. Added pending page mode mechanism: hook sets a flag when edit mode is requested but @page is nil, then Shop.Page calls maybe_enter_pending_page_mode after @page is assigned. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1549 lines
55 KiB
Elixir
1549 lines
55 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
|
|
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
|
|
|
if assigns[:is_admin] do
|
|
render_page_with_rail(assigns)
|
|
else
|
|
render_page_normal(assigns)
|
|
end
|
|
end
|
|
|
|
defp render_page_normal(assigns) do
|
|
~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_rail(assigns) do
|
|
~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)}>
|
|
<%= if @editing && @editing_blocks do %>
|
|
<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>
|
|
<% else %>
|
|
<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>
|
|
<% end %>
|
|
</main>
|
|
</.shop_layout>
|
|
|
|
<%!-- Editor sheet for page/theme/settings editing --%>
|
|
<.editor_sheet
|
|
editing={@editing}
|
|
theme_editing={Map.get(assigns, :theme_editing, false)}
|
|
editor_dirty={@editor_dirty}
|
|
theme_dirty={Map.get(assigns, :theme_dirty, false)}
|
|
site_dirty={Map.get(assigns, :site_dirty, false)}
|
|
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
|
editor_save_status={@editor_save_status}
|
|
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
|
editor_nav_blocked={Map.get(assigns, :editor_nav_blocked)}
|
|
has_editable_page={@page != nil}
|
|
>
|
|
<.editor_panel_content
|
|
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
|
page={@page}
|
|
editing_blocks={@editing_blocks}
|
|
editor_history={@editor_history}
|
|
editor_future={@editor_future}
|
|
editor_dirty={@editor_dirty}
|
|
editor_live_region_message={@editor_live_region_message}
|
|
editor_expanded={@editor_expanded}
|
|
editor_show_picker={@editor_show_picker}
|
|
editor_picker_filter={@editor_picker_filter}
|
|
editor_allowed_blocks={@editor_allowed_blocks}
|
|
editor_image_picker_block_id={@editor_image_picker_block_id}
|
|
editor_image_picker_images={@editor_image_picker_images}
|
|
editor_image_picker_search={@editor_image_picker_search}
|
|
editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)}
|
|
theme_editor_settings={Map.get(assigns, :theme_editor_settings)}
|
|
theme_editor_active_preset={Map.get(assigns, :theme_editor_active_preset)}
|
|
theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])}
|
|
theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)}
|
|
theme_editor_logo_image={Map.get(assigns, :theme_editor_logo_image)}
|
|
theme_editor_header_image={Map.get(assigns, :theme_editor_header_image)}
|
|
theme_editor_icon_image={Map.get(assigns, :theme_editor_icon_image)}
|
|
theme_editor_contrast_warning={Map.get(assigns, :theme_editor_contrast_warning, :ok)}
|
|
uploads={Map.get(assigns, :uploads)}
|
|
site_name={Map.get(assigns, :site_name, "")}
|
|
settings_form={Map.get(assigns, :settings_form)}
|
|
settings_dirty={Map.get(assigns, :settings_dirty, false)}
|
|
settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
|
|
site_state={Map.get(assigns, :site_state)}
|
|
site_nav_pages={Map.get(assigns, :site_nav_pages, [])}
|
|
/>
|
|
</.editor_sheet>
|
|
"""
|
|
end
|
|
|
|
# Editor panel content dispatcher - shows content based on active tab
|
|
attr :editor_active_tab, :atom, default: :page
|
|
attr :page, :map, default: nil
|
|
attr :editing_blocks, :list, default: nil
|
|
attr :editor_history, :list, default: []
|
|
attr :editor_future, :list, default: []
|
|
attr :editor_dirty, :boolean, default: false
|
|
attr :editor_live_region_message, :string, default: nil
|
|
attr :editor_expanded, :any, default: nil
|
|
attr :editor_show_picker, :boolean, default: false
|
|
attr :editor_picker_filter, :string, default: ""
|
|
attr :editor_allowed_blocks, :list, default: nil
|
|
attr :editor_image_picker_block_id, :string, default: nil
|
|
attr :editor_image_picker_images, :list, default: []
|
|
attr :editor_image_picker_search, :string, default: ""
|
|
attr :editor_at_defaults, :boolean, default: true
|
|
attr :theme_editor_settings, :map, default: nil
|
|
attr :theme_editor_active_preset, :atom, default: nil
|
|
attr :theme_editor_presets, :list, default: []
|
|
attr :theme_editor_customise_open, :boolean, default: false
|
|
attr :theme_editor_logo_image, :map, default: nil
|
|
attr :theme_editor_header_image, :map, default: nil
|
|
attr :theme_editor_icon_image, :map, default: nil
|
|
attr :theme_editor_contrast_warning, :atom, default: :ok
|
|
attr :uploads, :map, default: nil
|
|
attr :site_name, :string, default: ""
|
|
attr :settings_form, :map, default: nil
|
|
attr :settings_dirty, :boolean, default: false
|
|
attr :settings_save_status, :atom, default: :idle
|
|
attr :site_state, :any, default: nil
|
|
attr :site_nav_pages, :list, default: []
|
|
|
|
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
|
|
~H"""
|
|
<.editor_sheet_content
|
|
page={@page}
|
|
editing_blocks={@editing_blocks}
|
|
editor_history={@editor_history}
|
|
editor_future={@editor_future}
|
|
editor_dirty={@editor_dirty}
|
|
editor_live_region_message={@editor_live_region_message}
|
|
editor_expanded={@editor_expanded}
|
|
editor_show_picker={@editor_show_picker}
|
|
editor_picker_filter={@editor_picker_filter}
|
|
editor_allowed_blocks={@editor_allowed_blocks}
|
|
editor_image_picker_block_id={@editor_image_picker_block_id}
|
|
editor_image_picker_images={@editor_image_picker_images}
|
|
editor_image_picker_search={@editor_image_picker_search}
|
|
editor_at_defaults={@editor_at_defaults}
|
|
settings_form={@settings_form}
|
|
settings_dirty={@settings_dirty}
|
|
settings_save_status={@settings_save_status}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp editor_panel_content(%{editor_active_tab: :theme} = assigns) do
|
|
~H"""
|
|
<.theme_editor_content
|
|
theme_editor_settings={@theme_editor_settings}
|
|
theme_editor_active_preset={@theme_editor_active_preset}
|
|
theme_editor_presets={@theme_editor_presets}
|
|
theme_editor_customise_open={@theme_editor_customise_open}
|
|
theme_editor_logo_image={@theme_editor_logo_image}
|
|
theme_editor_header_image={@theme_editor_header_image}
|
|
theme_editor_icon_image={@theme_editor_icon_image}
|
|
theme_editor_contrast_warning={@theme_editor_contrast_warning}
|
|
uploads={@uploads}
|
|
site_name={@site_name}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp editor_panel_content(%{editor_active_tab: :site} = assigns) do
|
|
~H"""
|
|
<BerrypodWeb.ShopComponents.SiteEditor.site_editor
|
|
site_state={@site_state}
|
|
site_nav_pages={@site_nav_pages}
|
|
site_name={@site_name}
|
|
theme_settings={@theme_editor_settings}
|
|
logo_image={@theme_editor_logo_image}
|
|
header_image={@theme_editor_header_image}
|
|
icon_image={@theme_editor_icon_image}
|
|
contrast_warning={@theme_editor_contrast_warning}
|
|
uploads={@uploads}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
# Theme editor content - uses shared component
|
|
attr :theme_editor_settings, :map, default: nil
|
|
attr :theme_editor_active_preset, :atom, default: nil
|
|
attr :theme_editor_presets, :list, default: []
|
|
attr :theme_editor_customise_open, :boolean, default: false
|
|
attr :theme_editor_logo_image, :map, default: nil
|
|
attr :theme_editor_header_image, :map, default: nil
|
|
attr :theme_editor_icon_image, :map, default: nil
|
|
attr :theme_editor_contrast_warning, :atom, default: :ok
|
|
attr :uploads, :map, default: nil
|
|
attr :site_name, :string, default: ""
|
|
|
|
defp theme_editor_content(assigns) do
|
|
~H"""
|
|
<.compact_editor
|
|
theme_settings={@theme_editor_settings}
|
|
active_preset={@theme_editor_active_preset}
|
|
presets={@theme_editor_presets}
|
|
site_name={@site_name}
|
|
customise_open={@theme_editor_customise_open}
|
|
uploads={@uploads}
|
|
logo_image={@theme_editor_logo_image}
|
|
header_image={@theme_editor_header_image}
|
|
icon_image={@theme_editor_icon_image}
|
|
contrast_warning={@theme_editor_contrast_warning}
|
|
event_prefix="theme_"
|
|
/>
|
|
"""
|
|
end
|
|
|
|
# Editor sheet content - the block list and editing controls
|
|
attr :page, :map, default: nil
|
|
attr :editing_blocks, :list, default: nil
|
|
attr :editor_history, :list, default: []
|
|
attr :editor_future, :list, default: []
|
|
attr :editor_dirty, :boolean, default: false
|
|
attr :editor_live_region_message, :string, default: nil
|
|
attr :editor_expanded, :any, default: nil
|
|
attr :editor_show_picker, :boolean, default: false
|
|
attr :editor_picker_filter, :string, default: ""
|
|
attr :editor_allowed_blocks, :list, default: nil
|
|
attr :editor_image_picker_block_id, :string, default: nil
|
|
attr :editor_image_picker_images, :list, default: []
|
|
attr :editor_image_picker_search, :string, default: ""
|
|
attr :editor_at_defaults, :boolean, default: true
|
|
attr :settings_form, :map, default: nil
|
|
attr :settings_dirty, :boolean, default: false
|
|
attr :settings_save_status, :atom, default: :idle
|
|
|
|
defp editor_sheet_content(assigns) do
|
|
~H"""
|
|
<div id="editor-sheet-inner" phx-hook="EditorKeyboard" data-dirty={to_string(@editor_dirty)}>
|
|
<%!-- Page title and undo/redo --%>
|
|
<div class="editor-panel-page-header">
|
|
<h3 class="editor-panel-page-title">{@page.title}</h3>
|
|
<div class="editor-panel-undo-redo">
|
|
<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>
|
|
</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>
|
|
|
|
<%!-- Page settings (custom and system pages, not product/collection) --%>
|
|
<.page_settings_section
|
|
:if={@page[:type] in ["custom", "system"]}
|
|
page={@page}
|
|
form={@settings_form}
|
|
dirty={@settings_dirty}
|
|
save_status={@settings_save_status}
|
|
/>
|
|
|
|
<%!-- 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 || MapSet.new()}
|
|
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>
|
|
<button
|
|
phx-click="editor_reset_defaults"
|
|
data-confirm="Reset this page to its default blocks? You can undo this."
|
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
|
disabled={@editor_at_defaults}
|
|
>
|
|
<.icon name="hero-arrow-path" class="size-4" /> Reset to defaults
|
|
</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_"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# Page settings section (collapsible) for custom and system pages
|
|
attr :page, :map, required: true
|
|
attr :form, :map, default: nil
|
|
attr :dirty, :boolean, default: false
|
|
attr :save_status, :atom, default: :idle
|
|
|
|
defp page_settings_section(assigns) do
|
|
form = assigns.form || %{}
|
|
is_custom = assigns.page[:type] == "custom"
|
|
|
|
page = assigns.page
|
|
|
|
assigns =
|
|
assigns
|
|
|> assign(:is_custom, is_custom)
|
|
|> assign(:form_title, form["title"] || page[:title] || "")
|
|
|> assign(:form_slug, form["slug"] || page[:slug] || "")
|
|
|> assign(:form_meta, form["meta_description"] || page[:meta_description] || "")
|
|
|> assign(:form_published, form_checked?(form, "published", page[:published]))
|
|
|> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", page[:show_in_nav]))
|
|
|> assign(:form_nav_label, form["nav_label"] || page[:nav_label] || "")
|
|
|> assign(
|
|
:form_nav_position,
|
|
form["nav_position"] || to_string(page[:nav_position] || 0)
|
|
)
|
|
|
|
~H"""
|
|
<details class="page-settings-details" open>
|
|
<summary class="page-settings-summary">
|
|
<.icon name="hero-cog-6-tooth-mini" class="size-4" />
|
|
<span>Page settings</span>
|
|
<.icon name="hero-chevron-down-mini" class="size-4 page-settings-chevron" />
|
|
</summary>
|
|
|
|
<form
|
|
id="page-settings-form"
|
|
class="page-settings-form"
|
|
phx-change="settings_validate_page"
|
|
phx-submit="settings_save_page"
|
|
>
|
|
<div class="page-settings-field">
|
|
<label class="page-settings-label" for="page-settings-title">Title</label>
|
|
<input
|
|
type="text"
|
|
id="page-settings-title"
|
|
name="page[title]"
|
|
value={@form_title}
|
|
class="admin-input"
|
|
/>
|
|
</div>
|
|
|
|
<div class="page-settings-field">
|
|
<label class="page-settings-label" for="page-settings-slug">URL slug</label>
|
|
<div class="page-settings-slug-input">
|
|
<span class="page-settings-slug-prefix">/</span>
|
|
<input
|
|
type="text"
|
|
id="page-settings-slug"
|
|
name="page[slug]"
|
|
value={@form_slug}
|
|
class={["admin-input", !@is_custom && "admin-input-disabled"]}
|
|
pattern="[a-z0-9-]+"
|
|
disabled={!@is_custom}
|
|
title={if !@is_custom, do: "System page URLs cannot be changed", else: nil}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page-settings-field">
|
|
<label class="page-settings-label" for="page-settings-meta">Meta description</label>
|
|
<textarea
|
|
id="page-settings-meta"
|
|
name="page[meta_description]"
|
|
rows="2"
|
|
class="admin-input admin-textarea"
|
|
placeholder="Brief description for search engines..."
|
|
>{@form_meta}</textarea>
|
|
</div>
|
|
|
|
<%!-- Published and nav options only for custom pages --%>
|
|
<div :if={@is_custom} class="page-settings-checks">
|
|
<label class="admin-check-label">
|
|
<input
|
|
type="checkbox"
|
|
name="page[published]"
|
|
value="true"
|
|
checked={@form_published}
|
|
class="admin-checkbox admin-checkbox-sm"
|
|
/>
|
|
<span>Published</span>
|
|
</label>
|
|
|
|
<label class="admin-check-label">
|
|
<input
|
|
type="checkbox"
|
|
name="page[show_in_nav]"
|
|
value="true"
|
|
checked={@form_show_in_nav}
|
|
class="admin-checkbox admin-checkbox-sm"
|
|
/>
|
|
<span>Show in navigation</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div :if={@is_custom && @form_show_in_nav} class="page-settings-nav-options">
|
|
<div class="page-settings-field page-settings-field-inline">
|
|
<label class="page-settings-label" for="page-settings-nav-label">Nav label</label>
|
|
<input
|
|
type="text"
|
|
id="page-settings-nav-label"
|
|
name="page[nav_label]"
|
|
value={@form_nav_label}
|
|
class="admin-input"
|
|
placeholder={@page.title}
|
|
/>
|
|
</div>
|
|
<div class="page-settings-field page-settings-field-sm">
|
|
<label class="page-settings-label" for="page-settings-nav-position">Position</label>
|
|
<input
|
|
type="number"
|
|
id="page-settings-nav-position"
|
|
name="page[nav_position]"
|
|
value={@form_nav_position}
|
|
class="admin-input"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
"""
|
|
end
|
|
|
|
defp form_checked?(form, key, page_value) when is_map(form) do
|
|
case form[key] do
|
|
"true" -> true
|
|
"false" -> false
|
|
nil -> page_value == true
|
|
_ -> page_value == true
|
|
end
|
|
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 links={assigns[:social_links] || []} />"
|
|
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} />
|
|
|
|
<p
|
|
:if={assigns[:product_discontinued]}
|
|
class="variant-unavailable-msg"
|
|
>
|
|
This product is no longer available
|
|
</p>
|
|
|
|
<p
|
|
:if={
|
|
!assigns[:product_discontinued] && assigns[:selected_variant] &&
|
|
!assigns[:selected_variant].is_available
|
|
}
|
|
class="variant-unavailable-msg"
|
|
>
|
|
This option is currently unavailable
|
|
</p>
|
|
|
|
<.add_to_cart_button
|
|
mode={@mode}
|
|
text={
|
|
if assigns[:product_discontinued], do: "No longer available", else: "Add to basket"
|
|
}
|
|
disabled={
|
|
assigns[:product_discontinued] ||
|
|
(assigns[:selected_variant] && !assigns[:selected_variant].is_available)
|
|
}
|
|
/>
|
|
</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
|
|
patch={collection_path("all", @current_sort)}
|
|
aria-current={@current_slug == nil && "page"}
|
|
class={["collection-filter-pill", @current_slug == nil && "active"]}
|
|
>
|
|
All
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link
|
|
patch={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
|
|
patch={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 patch={~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 patch="/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 patch="/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 patch="/contact">get in touch</.link>.
|
|
</p>
|
|
</div>
|
|
<% true -> %>
|
|
<div class="orders-list">
|
|
<%= for order <- assigns[:orders] do %>
|
|
<.link patch={"/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 patch="/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 %>
|
|
<.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
|
|
patch={"/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 patch="/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
|
|
patch={@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
|