defmodule BerrypodWeb.PageRenderer do @moduledoc """ Generic page renderer for the page builder. One `render_page/1` renders ALL pages — no page-specific render functions. Each page is a flat list of blocks rendered in order. Block components dispatch to existing shop components. """ use Phoenix.Component use BerrypodWeb.ShopComponents use Phoenix.VerifiedRoutes, endpoint: BerrypodWeb.Endpoint, router: BerrypodWeb.Router, statics: BerrypodWeb.static_paths() import BerrypodWeb.BlockEditorComponents import BerrypodWeb.CoreComponents, only: [icon: 1] alias Berrypod.Cart # ── Public API ────────────────────────────────────────────────── @doc """ Renders a full page from its block definition. Expects `@page` (with `:slug` and `:blocks`) plus all the standard layout assigns (theme_settings, cart_items, etc.). When `@editing` is true and `@editing_blocks` is set (admin using the live page editor), wraps the page in a sidebar + content layout. """ def render_page(assigns) do if assigns[:editing] && assigns[:editing_blocks] do render_page_with_editor(assigns) else render_page_normal(assigns) end end defp render_page_normal(assigns) do assigns = assign(assigns, :block_assigns, block_assigns(assigns)) ~H""" <.shop_layout {layout_assigns(assigns)} active_page={@page.slug} error_page={@page.slug == "error"} >
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
""" end defp render_page_with_editor(assigns) do assigns = assign(assigns, :block_assigns, block_assigns(assigns)) ~H"""
<%!-- Backdrop: tapping the page dismisses the sidebar --%> """ 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"] || %{} image_url = resolve_block_image_url(settings["image_id"], settings["image_url"]) assigns = assigns |> assign(:section_title, settings["title"] || "") |> assign(:section_description, settings["description"] || "") |> assign(:image_url, image_url) |> assign(:link_text, settings["link_text"]) |> assign(:link_href, settings["link_href"]) ~H""" <.image_text_section title={@section_title} description={@section_description} image_url={@image_url} link_text={@link_text} link_href={@link_href} mode={@mode} /> """ end defp render_block(%{block: %{"type" => "newsletter_card"}} = assigns) do ~H"<.newsletter_card />" end defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do ~H"<.social_links_card />" end defp render_block(%{block: %{"type" => "info_card"}} = assigns) do settings = assigns.block["settings"] || %{} items = (settings["items"] || []) |> Enum.map(fn item -> %{label: item["label"] || item[:label], value: item["value"] || item[:value]} end) assigns = assigns |> assign(:card_title, settings["title"] || "") |> assign(:items, items) ~H""" <.info_card title={@card_title} items={@items} /> """ end defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do ~H""" <.trust_badges :if={@theme_settings.pdp_trust_badges} /> """ end defp render_block(%{block: %{"type" => "reviews_section"}} = assigns) do ~H""" <.reviews_section :if={@theme_settings.pdp_reviews} reviews={assigns[:reviews] || []} average_rating={assigns[:average_rating] || 5} total_count={assigns[:total_count]} /> """ end # ── PDP blocks ────────────────────────────────────────────────── defp render_block(%{block: %{"type" => "breadcrumb"}} = assigns) do ~H""" <.breadcrumb :if={assigns[:product]} items={breadcrumb_items(assigns[:product])} mode={@mode} /> """ end defp render_block(%{block: %{"type" => "product_hero"}} = assigns) do ~H""" <%= if assigns[:product] do %>
<.product_gallery images={assigns[:gallery_images] || []} product_name={@product.title} />
<.product_info product={@product} display_price={assigns[:display_price]} />
<%= 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 %>
One size
<.quantity_selector quantity={assigns[:quantity] || 1} in_stock={@product.in_stock} /> <.add_to_cart_button mode={@mode} />
<% end %> """ end defp render_block(%{block: %{"type" => "product_details"}} = assigns) do ~H""" <.product_details :if={assigns[:product]} product={@product} /> """ end defp render_block(%{block: %{"type" => "related_products"}} = assigns) do ~H""" <.related_products_section :if={@theme_settings.pdp_related_products && assigns[:related_products] != []} products={assigns[:related_products] || []} theme_settings={@theme_settings} mode={@mode} /> """ end # ── Collection blocks ─────────────────────────────────────────── defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do ~H""" <.collection_header title={assigns[:collection_title] || "All Products"} product_count={length(assigns[:products] || [])} /> """ end defp render_block(%{block: %{"type" => "filter_bar"}} = assigns) do current_slug = case assigns[:current_category] do :sale -> "sale" nil -> nil cat -> cat.slug end assigns = assigns |> assign(:current_slug, current_slug) |> assign(:current_sort, assigns[:current_sort] || "featured") |> assign(:sort_options, assigns[:sort_options] || []) ~H"""
<.shop_select name="sort" options={@sort_options} selected={@current_sort} aria-label="Sort products" />
""" end defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do show_category = assigns[:current_category] in [nil, :sale] assigns = assign(assigns, :show_category, show_category) ~H"""
<.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 %> <%= if (assigns[:products] || []) == [] do %>

No products found in this collection.

<.link navigate={~p"/collections/all"} class="collection-empty-link"> View all products
<% 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 %> <% end %> """ end defp render_block(%{block: %{"type" => "order_summary"}} = assigns) do ~H""" <%= if @cart_items != [] do %> <.order_summary subtotal={assigns[:cart_page_subtotal] || 0} shipping_estimate={assigns[:shipping_estimate]} country_code={assigns[:country_code] || "GB"} available_countries={assigns[:available_countries] || []} mode={@mode} /> <% end %> """ end # ── Contact blocks ────────────────────────────────────────────── defp render_block(%{block: %{"type" => "contact_form"}} = assigns) do settings = assigns.block["settings"] || %{} assigns = assign(assigns, :email, settings["email"]) ~H""" <.contact_form email={@email} /> """ end defp render_block(%{block: %{"type" => "order_tracking_card"}} = assigns) do ~H""" <.order_tracking_card tracking_state={assigns[:tracking_state] || :idle} /> """ end # ── Content blocks ────────────────────────────────────────────── defp render_block(%{block: %{"type" => "content_body"}} = assigns) do settings = assigns.block["settings"] || %{} content = settings["content"] || "" {image_src, image_alt} = resolve_content_image(settings) assigns = assigns |> assign(:image_src, image_src) |> assign(:image_alt, image_alt) |> assign(:content, content) ~H"""
<%= 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 %> <%= if @content != "" do %> <%!-- Inline content from block settings — split on blank lines for paragraphs --%>

{para}

<% else %> <%!-- Structured rich text from LiveView (content pages) --%> <.rich_text blocks={assigns[:content_blocks] || []} /> <% end %>
""" end # ── Checkout success ──────────────────────────────────────────── defp render_block(%{block: %{"type" => "checkout_result"}} = assigns) do ~H""" <%= if assigns[:order] && assigns[:order].payment_status == "paid" do %>

Thank you for your order

Order {assigns[:order].order_number}

<%= if assigns[:order].customer_email do %>

A confirmation will be sent to {assigns[:order].customer_email}

<% end %>
<.shop_card class="checkout-card">

Order details

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 %>

Processing your payment

Please wait while we confirm your payment. This usually takes a few seconds.

If this page doesn't update, please <.link navigate="/contact" class="checkout-contact-link">contact us.

<% end %> """ end # ── Orders ────────────────────────────────────────────────────── defp render_block(%{block: %{"type" => "order_card"}} = assigns) do ~H"""

Your orders

<%= if assigns[:lookup_email] do %>

Orders for {assigns[:lookup_email]}

<% end %>
<%= 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)}
<% end %>
<% end %> """ end # ── Order detail ──────────────────────────────────────────────── defp render_block(%{block: %{"type" => "order_detail_card"}} = assigns) do ~H""" <%= if assigns[:order] do %>
<.link navigate="/orders" class="order-detail-back">← Back to orders

{assigns[:order].order_number}

{Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}

{format_order_status(assigns[:order].fulfilment_status)}
<%= 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

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 # ── Utility blocks ────────────────────────────────────────────── defp render_block(%{block: %{"type" => "spacer"}} = assigns) do size = get_in(assigns.block, ["settings", "size"]) || "medium" assigns = assign(assigns, :size, size) ~H""" """ end defp render_block(%{block: %{"type" => "divider"}} = assigns) do style = get_in(assigns.block, ["settings", "style"]) || "line" assigns = assign(assigns, :style, style) ~H"""
""" 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"""
<.link navigate={@href} class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"} > {@text}
""" 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"""

{if @caption != "", do: @caption, else: "Watch video"}

{@caption}

""" 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("collection"), do: nil def page_main_class("pdp"), do: "page-container" def page_main_class("search"), do: "page-container" def page_main_class("about"), do: "content-page" def page_main_class("delivery"), do: "content-page" def page_main_class("privacy"), do: "content-page" def page_main_class("terms"), do: "content-page" def page_main_class(_), do: nil # Extracts the assigns that individual blocks need (everything except page metadata). # Preserves __changed__ so assign/3 works inside render_block functions. defp block_assigns(assigns) do Map.drop(assigns, [:page, :block_assigns]) end defp hero_variant("page"), do: :page defp hero_variant("sunken"), do: :default defp hero_variant("error"), do: :error defp hero_variant(_), do: :default defp hero_background("sunken"), do: :sunken defp hero_background(_), do: :base defp card_variant("minimal"), do: :minimal defp card_variant("compact"), do: :compact defp card_variant("default"), do: :default defp card_variant(_), do: :featured defp grid_columns("fixed-4"), do: :fixed_4 defp grid_columns(_), do: nil defp breadcrumb_items(%{category: cat, title: title}) when not is_nil(cat) do slug = cat |> String.downcase() |> String.replace(" ", "-") [ %{label: cat, page: "collection", href: "/collections/#{slug}"}, %{label: title, current: true} ] end defp breadcrumb_items(%{title: title}) do [%{label: title, current: true}] end defp breadcrumb_items(_), do: [] defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" # Resolves an image_id to a URL, falling back to a legacy URL string defp resolve_block_image_url(image_id, fallback_url) do case resolve_image(image_id) do {url, _alt} -> url nil -> fallback_url || "" end end # Resolves image_id for content_body blocks, returning {src, alt} defp resolve_content_image(settings) do case resolve_image(settings["image_id"]) do {src, alt} -> {src, alt} nil -> {settings["image_src"], settings["image_alt"] || ""} end end defp resolve_image(nil), do: nil defp resolve_image(""), do: nil defp resolve_image(image_id) do case Berrypod.Media.get_image(image_id) do nil -> nil image -> url = if image.is_svg do "/image_cache/#{image.id}.webp" else # Pick the largest variant that was actually generated width = image.source_width |> Berrypod.Images.Optimizer.applicable_widths() |> List.last() "/image_cache/#{image.id}-#{width || 400}.webp" end {url, image.alt || image.filename} end end @youtube_re ~r/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ @vimeo_re ~r/vimeo\.com\/(?:video\/)?(\d+)/ defp parse_video_url(url) when is_binary(url) do cond do match = Regex.run(@youtube_re, url) -> {:youtube, "https://www.youtube-nocookie.com/embed/#{Enum.at(match, 1)}"} match = Regex.run(@vimeo_re, url) -> {:vimeo, "https://player.vimeo.com/video/#{Enum.at(match, 1)}?dnt=1"} true -> {:unknown, nil} end end defp parse_video_url(_), do: {:unknown, nil} def format_order_status("unfulfilled"), do: "Being prepared" def format_order_status("submitted"), do: "Sent to printer" def format_order_status("processing"), do: "In production" def format_order_status("shipped"), do: "On its way" def format_order_status("delivered"), do: "Delivered" def format_order_status("failed"), do: "Issue — contact us" def format_order_status("cancelled"), do: "Cancelled" def format_order_status(status), do: status end