add generic page renderer with block dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
35f96e43a6
commit
32f54c7afc
755
lib/berrypod_web/page_renderer.ex
Normal file
755
lib/berrypod_web/page_renderer.ex
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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.).
|
||||||
|
"""
|
||||||
|
def render_page(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)}>
|
||||||
|
<%= for block <- @page.blocks do %>
|
||||||
|
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
|
||||||
|
<% end %>
|
||||||
|
</main>
|
||||||
|
</.shop_layout>
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<.featured_products_section
|
||||||
|
title={@section_title}
|
||||||
|
products={assigns[:products] || []}
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
mode={@mode}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "image_text"}} = assigns) do
|
||||||
|
settings = assigns.block["settings"] || %{}
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:section_title, settings["title"] || "")
|
||||||
|
|> assign(:section_description, settings["description"] || "")
|
||||||
|
|> assign(:image_url, settings["image_url"] || "")
|
||||||
|
|> assign(:link_text, settings["link_text"])
|
||||||
|
|> assign(:link_href, settings["link_href"])
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<.image_text_section
|
||||||
|
title={@section_title}
|
||||||
|
description={@section_description}
|
||||||
|
image_url={@image_url}
|
||||||
|
link_text={@link_text}
|
||||||
|
link_href={@link_href}
|
||||||
|
mode={@mode}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "newsletter_card"}} = assigns) do
|
||||||
|
~H"<.newsletter_card />"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
||||||
|
~H"<.social_links_card />"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do
|
||||||
|
settings = assigns.block["settings"] || %{}
|
||||||
|
|
||||||
|
items =
|
||||||
|
(settings["items"] || [])
|
||||||
|
|> Enum.map(fn item ->
|
||||||
|
%{label: item["label"] || item[:label], value: item["value"] || item[:value]}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:card_title, settings["title"] || "")
|
||||||
|
|> assign(:items, items)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<.info_card title={@card_title} items={@items} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "reviews_section"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.reviews_section
|
||||||
|
:if={@theme_settings.pdp_reviews}
|
||||||
|
reviews={assigns[:reviews] || []}
|
||||||
|
average_rating={assigns[:average_rating] || 5}
|
||||||
|
total_count={assigns[:total_count]}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── PDP blocks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "breadcrumb"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.breadcrumb
|
||||||
|
:if={assigns[:product]}
|
||||||
|
items={breadcrumb_items(assigns[:product])}
|
||||||
|
mode={@mode}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "product_hero"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= if assigns[:product] do %>
|
||||||
|
<div class="pdp-grid">
|
||||||
|
<.product_gallery images={assigns[:gallery_images] || []} product_name={@product.title} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<.product_info product={@product} display_price={assigns[:display_price]} />
|
||||||
|
|
||||||
|
<form action="/cart/add" method="post" phx-submit="add_to_cart">
|
||||||
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="variant_id"
|
||||||
|
value={assigns[:selected_variant] && assigns[:selected_variant].id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%= for option_type <- assigns[:option_types] || [] do %>
|
||||||
|
<.variant_selector
|
||||||
|
option_type={option_type}
|
||||||
|
selected={(assigns[:selected_options] || %{})[option_type.name]}
|
||||||
|
available={(assigns[:available_options] || %{})[option_type.name] || []}
|
||||||
|
mode={@mode}
|
||||||
|
option_urls={(assigns[:option_urls] || %{})[option_type.name] || %{}}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={(assigns[:option_types] || []) == []}
|
||||||
|
class="pdp-variant-fallback"
|
||||||
|
>
|
||||||
|
One size
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.quantity_selector quantity={assigns[:quantity] || 1} in_stock={@product.in_stock} />
|
||||||
|
<.add_to_cart_button mode={@mode} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "product_details"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.product_details :if={assigns[:product]} product={@product} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "related_products"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.related_products_section
|
||||||
|
:if={@theme_settings.pdp_related_products && assigns[:related_products] != []}
|
||||||
|
products={assigns[:related_products] || []}
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
mode={@mode}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Collection blocks ───────────────────────────────────────────
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<.collection_header
|
||||||
|
title={assigns[:collection_title] || "All Products"}
|
||||||
|
product_count={length(assigns[:products] || [])}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "filter_bar"}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="page-container">
|
||||||
|
<.filter_bar categories={assigns[:categories] || []} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
|
||||||
|
~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={true}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
</.product_grid>
|
||||||
|
</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"] || %{}
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:image_src, settings["image_src"])
|
||||||
|
|> assign(:image_alt, settings["image_alt"] || "")
|
||||||
|
|
||||||
|
~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 %>
|
||||||
|
|
||||||
|
<.rich_text blocks={assigns[:content_blocks] || []} />
|
||||||
|
</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
|
||||||
|
|
||||||
|
# ── 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("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 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: []
|
||||||
|
|
||||||
|
# Reuse from PageTemplates
|
||||||
|
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
|
||||||
@ -1,9 +1,15 @@
|
|||||||
defmodule Berrypod.PagesTest do
|
defmodule Berrypod.PagesTest do
|
||||||
use Berrypod.DataCase, async: true
|
use Berrypod.DataCase, async: false
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
|
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Clear cached pages between tests so save_page side effects don't leak
|
||||||
|
PageCache.invalidate_all()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
describe "get_page/1" do
|
describe "get_page/1" do
|
||||||
test "returns defaults when nothing in DB" do
|
test "returns defaults when nothing in DB" do
|
||||||
page = Pages.get_page("home")
|
page = Pages.get_page("home")
|
||||||
|
|||||||
242
test/berrypod_web/page_renderer_test.exs
Normal file
242
test/berrypod_web/page_renderer_test.exs
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
defmodule BerrypodWeb.PageRendererTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Berrypod.Pages
|
||||||
|
alias Berrypod.Settings.ThemeSettings
|
||||||
|
alias BerrypodWeb.PageRenderer
|
||||||
|
|
||||||
|
# Minimal assigns that every page needs (the stuff shop_layout requires)
|
||||||
|
defp base_assigns do
|
||||||
|
%{
|
||||||
|
__changed__: nil,
|
||||||
|
theme_settings: %ThemeSettings{},
|
||||||
|
logo_image: nil,
|
||||||
|
header_image: nil,
|
||||||
|
mode: :shop,
|
||||||
|
cart_items: [],
|
||||||
|
cart_count: 0,
|
||||||
|
cart_subtotal: "£0.00",
|
||||||
|
cart_total: nil,
|
||||||
|
cart_drawer_open: false,
|
||||||
|
cart_status: nil,
|
||||||
|
is_admin: false,
|
||||||
|
search_query: "",
|
||||||
|
search_results: [],
|
||||||
|
search_open: false,
|
||||||
|
categories: [],
|
||||||
|
shipping_estimate: nil,
|
||||||
|
country_code: "GB",
|
||||||
|
available_countries: [],
|
||||||
|
flash: %{}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_page(slug, extra_assigns \\ %{}) do
|
||||||
|
page = Pages.get_page(slug)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
base_assigns()
|
||||||
|
|> Map.merge(extra_assigns)
|
||||||
|
|> Map.put(:page, page)
|
||||||
|
|
||||||
|
PageRenderer.render_page(assigns)
|
||||||
|
|> rendered_to_string()
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "render_page/1" do
|
||||||
|
test "home page renders hero and featured products" do
|
||||||
|
html = render_page("home", %{products: []})
|
||||||
|
|
||||||
|
assert html =~ "Original designs, printed on demand"
|
||||||
|
assert html =~ "Shop the collection"
|
||||||
|
assert html =~ "Featured products"
|
||||||
|
assert html =~ "Made with passion, printed with care"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "about page renders hero and content area" do
|
||||||
|
html =
|
||||||
|
render_page("about", %{content_blocks: [%{type: :paragraph, text: "Test about text"}]})
|
||||||
|
|
||||||
|
assert html =~ "About the studio"
|
||||||
|
assert html =~ "content-body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delivery page renders hero" do
|
||||||
|
html = render_page("delivery", %{content_blocks: []})
|
||||||
|
|
||||||
|
assert html =~ "Delivery information"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "privacy page renders hero" do
|
||||||
|
html = render_page("privacy", %{content_blocks: []})
|
||||||
|
|
||||||
|
assert html =~ "Privacy policy"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "terms page renders hero" do
|
||||||
|
html = render_page("terms", %{content_blocks: []})
|
||||||
|
|
||||||
|
assert html =~ "Terms & conditions"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "contact page renders hero, form, and sidebar blocks" do
|
||||||
|
html = render_page("contact", %{tracking_state: :idle})
|
||||||
|
|
||||||
|
assert html =~ "Get in touch"
|
||||||
|
assert html =~ "Send a message"
|
||||||
|
assert html =~ "Track your order"
|
||||||
|
assert html =~ "Handy to know"
|
||||||
|
assert html =~ "Newsletter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "collection page renders header, filter bar, and grid" do
|
||||||
|
html = render_page("collection", %{products: [], collection_title: "All Products"})
|
||||||
|
|
||||||
|
assert html =~ "All Products"
|
||||||
|
assert html =~ "filter-bar"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pdp page renders breadcrumb and product hero when product provided" do
|
||||||
|
product = %{
|
||||||
|
id: "test-id",
|
||||||
|
title: "Test Product",
|
||||||
|
slug: "test-product",
|
||||||
|
category: "Art Prints",
|
||||||
|
description: "A lovely test product",
|
||||||
|
cheapest_price: 2500,
|
||||||
|
compare_at_price: nil,
|
||||||
|
on_sale: false,
|
||||||
|
in_stock: true,
|
||||||
|
provider_data: %{}
|
||||||
|
}
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_page("pdp", %{
|
||||||
|
product: product,
|
||||||
|
gallery_images: [],
|
||||||
|
display_price: 2500,
|
||||||
|
selected_variant: nil,
|
||||||
|
option_types: [],
|
||||||
|
selected_options: %{},
|
||||||
|
available_options: %{},
|
||||||
|
option_urls: %{},
|
||||||
|
quantity: 1,
|
||||||
|
related_products: [],
|
||||||
|
reviews: [],
|
||||||
|
average_rating: 5,
|
||||||
|
total_count: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "Art Prints"
|
||||||
|
assert html =~ "Test Product"
|
||||||
|
assert html =~ "pdp-grid"
|
||||||
|
assert html =~ "Add to basket"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cart page renders empty state when no items" do
|
||||||
|
html = render_page("cart")
|
||||||
|
|
||||||
|
assert html =~ "Your basket"
|
||||||
|
assert html =~ "cart-empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cart page renders items when present" do
|
||||||
|
items = [
|
||||||
|
%{
|
||||||
|
variant_id: "v1",
|
||||||
|
name: "Test T-Shirt",
|
||||||
|
variant: "Black / M",
|
||||||
|
price: 2500,
|
||||||
|
quantity: 1,
|
||||||
|
image: nil,
|
||||||
|
product_id: "test-t-shirt",
|
||||||
|
in_stock: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_page("cart", %{
|
||||||
|
cart_items: items,
|
||||||
|
cart_count: 1,
|
||||||
|
cart_subtotal: "£25.00",
|
||||||
|
cart_page_subtotal: 2500
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "Your basket"
|
||||||
|
assert html =~ "cart-page-list"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "search page renders search form" do
|
||||||
|
html = render_page("search", %{search_page_query: "", search_page_results: []})
|
||||||
|
|
||||||
|
assert html =~ "Search"
|
||||||
|
assert html =~ ~s(name="q")
|
||||||
|
assert html =~ "Search products..."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "checkout success renders pending state when no order" do
|
||||||
|
html = render_page("checkout_success", %{order: nil})
|
||||||
|
|
||||||
|
assert html =~ "Processing your payment"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders page renders empty state when orders nil" do
|
||||||
|
html = render_page("orders", %{orders: nil, lookup_email: nil})
|
||||||
|
|
||||||
|
assert html =~ "Your orders"
|
||||||
|
assert html =~ "expired or is invalid"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders page renders no orders message" do
|
||||||
|
html = render_page("orders", %{orders: [], lookup_email: "test@example.com"})
|
||||||
|
|
||||||
|
assert html =~ "test@example.com"
|
||||||
|
assert html =~ "No orders found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "order detail page renders nothing when no order" do
|
||||||
|
html = render_page("order_detail", %{order: nil})
|
||||||
|
|
||||||
|
# Should not crash, just render empty
|
||||||
|
assert html =~ "main"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "error page renders error hero" do
|
||||||
|
html =
|
||||||
|
render_page("error", %{
|
||||||
|
error_code: "404",
|
||||||
|
error_title: "Page not found",
|
||||||
|
error_description: "Sorry, we couldn't find that page.",
|
||||||
|
products: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "404"
|
||||||
|
assert html =~ "Page not found"
|
||||||
|
assert html =~ "Go to Homepage"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "page_main_class/1" do
|
||||||
|
test "returns correct classes for each page" do
|
||||||
|
assert PageRenderer.page_main_class("contact") == "page-container contact-main"
|
||||||
|
assert PageRenderer.page_main_class("cart") == "page-container"
|
||||||
|
assert PageRenderer.page_main_class("checkout_success") == "page-container checkout-main"
|
||||||
|
assert PageRenderer.page_main_class("orders") == "page-container orders-main"
|
||||||
|
assert PageRenderer.page_main_class("order_detail") == "page-container order-detail-main"
|
||||||
|
assert PageRenderer.page_main_class("error") == "error-main"
|
||||||
|
assert PageRenderer.page_main_class("about") == "content-page"
|
||||||
|
assert PageRenderer.page_main_class("home") == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "format_order_status/1" do
|
||||||
|
test "maps status strings to display text" do
|
||||||
|
assert PageRenderer.format_order_status("unfulfilled") == "Being prepared"
|
||||||
|
assert PageRenderer.format_order_status("shipped") == "On its way"
|
||||||
|
assert PageRenderer.format_order_status("delivered") == "Delivered"
|
||||||
|
assert PageRenderer.format_order_status("unknown") == "unknown"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user