diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex new file mode 100644 index 0000000..65f77b2 --- /dev/null +++ b/lib/berrypod_web/page_renderer.ex @@ -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"} + > +
+ <%= for block <- @page.blocks do %> + {render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))} + <% end %> +
+ + """ + end + + # ── Block dispatch ────────────────────────────────────────────── + + defp render_block(%{block: %{"type" => "hero"}} = assigns) do + settings = assigns.block["settings"] || %{} + variant = hero_variant(settings["variant"]) + + # Page-context assigns (e.g. error_title, error_code) override block settings + assigns = + assigns + |> assign(:title, assigns[:error_title] || settings["title"] || "") + |> assign(:description, assigns[:error_description] || settings["description"] || "") + |> assign(:variant, variant) + |> assign(:background, hero_background(settings["variant"])) + |> assign(:pre_title, assigns[:error_code] || settings["pre_title"]) + |> assign(:cta_text, settings["cta_text"]) + |> assign(:cta_href, settings["cta_href"]) + |> assign(:secondary_cta_text, settings["secondary_cta_text"]) + |> assign(:secondary_cta_href, settings["secondary_cta_href"]) + + ~H""" + <.hero_section + title={@title} + description={@description} + variant={@variant} + background={@background} + pre_title={@pre_title} + cta_text={@cta_text} + cta_href={@cta_href} + secondary_cta_text={@secondary_cta_text} + secondary_cta_href={@secondary_cta_href} + mode={@mode} + /> + """ + end + + defp render_block(%{block: %{"type" => "category_nav"}} = assigns) do + ~H""" + <.category_nav categories={assigns[:categories] || []} mode={@mode} /> + """ + end + + defp render_block(%{block: %{"type" => "featured_products"}} = assigns) do + settings = assigns.block["settings"] || %{} + + assigns = + assigns + |> assign(:section_title, settings["title"] || "Featured products") + + ~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 %> +
+ <.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 + ~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 %> + + <% 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 %> +
+
+ + + +
+

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 + + # ── 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 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 diff --git a/test/berrypod/pages_test.exs b/test/berrypod/pages_test.exs index 35a78df..11a8e16 100644 --- a/test/berrypod/pages_test.exs +++ b/test/berrypod/pages_test.exs @@ -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") diff --git a/test/berrypod_web/page_renderer_test.exs b/test/berrypod_web/page_renderer_test.exs new file mode 100644 index 0000000..6efa944 --- /dev/null +++ b/test/berrypod_web/page_renderer_test.exs @@ -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