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
|
||||
use Berrypod.DataCase, async: true
|
||||
use Berrypod.DataCase, async: false
|
||||
|
||||
alias Berrypod.Pages
|
||||
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
|
||||
test "returns defaults when nothing in DB" do
|
||||
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