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"}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
"""
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 %>
<% 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"] || %{}
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 %>
<.product_gallery images={assigns[:gallery_images] || []} product_name={@product.title} />
<.product_info product={@product} display_price={assigns[:display_price]} />
<% 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"""
<.filter_bar categories={assigns[:categories] || []} />
"""
end
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
~H"""
<.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 %>
"""
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 %>
<%= for item <- @cart_items do %>
<.shop_card class="cart-page-card">
<.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} />
<% end %>
<% 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"""
<%= if @image_src && @image_src != "" do %>
<.responsive_image
src={@image_src}
source_width={1200}
alt={@image_alt}
sizes="(max-width: 800px) 100vw, 800px"
class="content-hero-image"
/>
<% end %>
<.rich_text blocks={assigns[:content_blocks] || []} />
"""
end
# ── Checkout success ────────────────────────────────────────────
defp render_block(%{block: %{"type" => "checkout_result"}} = assigns) do
~H"""
<%= if assigns[:order] && assigns[:order].payment_status == "paid" do %>
<.shop_card class="checkout-card">
Order details
<%= for item <- assigns[:order].items do %>
{item.product_name}
<%= if item.variant_title do %>
{item.variant_title}
<% end %>
Qty: {item.quantity}
{Cart.format_price(item.unit_price * item.quantity)}
<% end %>
Total
{Cart.format_price(assigns[:order].total)}
<%= if assigns[:order].shipping_address != %{} do %>
<.shop_card class="checkout-card">
Shipping to
{assigns[:order].shipping_address["name"]}
{assigns[:order].shipping_address["line1"]}
<%= if assigns[:order].shipping_address["line2"] do %>
{assigns[:order].shipping_address["line2"]}
<% end %>
{assigns[:order].shipping_address["city"]}, {assigns[:order].shipping_address[
"postal_code"
]}
{assigns[:order].shipping_address["country"]}
<% end %>
<.shop_link_button href="/collections/all" class="checkout-cta">
Continue shopping
<% else %>
<% end %>
"""
end
# ── Orders ──────────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "order_card"}} = assigns) do
~H"""
<%= cond do %>
<% is_nil(assigns[:orders]) -> %>
This link has expired or is invalid.
Head back to the <.link navigate="/contact">contact page to request a new one.
<% assigns[:orders] == [] -> %>
No orders found for that email address.
If something doesn't look right, <.link navigate="/contact">get in touch.
<% true -> %>
<%= for order <- assigns[:orders] do %>
<.link navigate={"/orders/#{order.order_number}"} class="order-summary-card">
{order.order_number}
{Calendar.strftime(order.inserted_at, "%-d %B %Y")}
{format_order_status(order.fulfilment_status)}
<%= for item <- Enum.take(order.items, 2) do %>
{item.quantity}× {item.product_name}
<%= if item.variant_title && item.variant_title != "" do %>
· {item.variant_title}
<% end %>
<% end %>
<%= if length(order.items) > 2 do %>
+{length(order.items) - 2} more
<% end %>
<% end %>
<% end %>
"""
end
# ── Order detail ────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "order_detail_card"}} = assigns) do
~H"""
<%= if assigns[:order] do %>
<%= if assigns[:order].tracking_number || assigns[:order].tracking_url do %>
<.shop_card class="order-detail-tracking-card">
Shipment tracking
<%= if assigns[:order].tracking_number do %>
{assigns[:order].tracking_number}
<% end %>
<%= if assigns[:order].tracking_url do %>
Track parcel
<% end %>
<% end %>
<.shop_card class="checkout-card">
Items ordered
<%= for item <- assigns[:order].items do %>
<% info = (assigns[:thumbnails] || %{})[item.variant_id] %>
<%= if info && info.thumb do %>
<% end %>
<%= if info && info.slug do %>
<.link
navigate={"/products/#{info.slug}"}
class="checkout-item-name checkout-item-link"
>
{item.product_name}
<% else %>
{item.product_name}
<% end %>
<%= if item.variant_title && item.variant_title != "" do %>
{item.variant_title}
<% end %>
Qty: {item.quantity}
{Cart.format_price(item.unit_price * item.quantity)}
<% end %>
Total
{Cart.format_price(assigns[:order].total)}
<%= if assigns[:order].shipping_address != %{} do %>
<.shop_card class="checkout-card">
Shipping to
{assigns[:order].shipping_address["name"]}
{assigns[:order].shipping_address["line1"]}
<%= if assigns[:order].shipping_address["line2"] do %>
{assigns[:order].shipping_address["line2"]}
<% end %>
{assigns[:order].shipping_address["city"]}, {assigns[:order].shipping_address[
"postal_code"
]}
{assigns[:order].shipping_address["country"]}
<% end %>
<.shop_link_button href="/collections/all">Continue shopping
<% end %>
"""
end
# ── Search ──────────────────────────────────────────────────────
defp render_block(%{block: %{"type" => "search_results"}} = assigns) do
~H"""
<.page_title text="Search" />
<%= if (assigns[:search_page_results] || []) != [] do %>
{length(assigns[:search_page_results])} {if length(assigns[:search_page_results]) == 1,
do: "result",
else: "results"} for "{assigns[:search_page_query]}"
<.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 %>
<% else %>
<%= if (assigns[:search_page_query] || "") != "" do %>
No products found for “{assigns[:search_page_query]}”
<.link navigate="/collections/all" class="collection-empty-link">Browse all products
<% 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 `` 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 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: []
# 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