From 16ebc29fa95a98e7fade3f2eb3d5c4f0e8c33ab7 Mon Sep 17 00:00:00 2001 From: jamey Date: Thu, 26 Feb 2026 19:13:00 +0000 Subject: [PATCH] wire collection, PDP, cart, and search pages to page renderer Stage 4 of the page builder: all shop pages now render via PageRenderer instead of inline templates or PageTemplates. - Collection: full filter bar moved to renderer (category pills, sort dropdown, CollectionFilters hook, empty state) - PDP: related_products and reviews loaded via block data loaders instead of manual queries - Cart: page definition loaded in mount, subtotal computed in render - Search: page definition loaded in mount, handle_params unchanged - Added Phoenix.VerifiedRoutes to PageRenderer for ~p sigil - Net -55 lines (128 added, 183 removed) Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 6 +- docs/plans/page-builder.md | 26 +++-- lib/berrypod_web/live/shop/cart.ex | 7 +- lib/berrypod_web/live/shop/collection.ex | 110 +-------------------- lib/berrypod_web/live/shop/product_show.ex | 20 ++-- lib/berrypod_web/live/shop/search.ex | 49 +-------- lib/berrypod_web/page_renderer.ex | 93 ++++++++++++++++- 7 files changed, 128 insertions(+), 183 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index b7bedcc..0cca7c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -458,7 +458,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan ### Page Editor -**Status:** In progress — Stage 3 of 9 complete, 1284 tests +**Status:** In progress — Stage 4 of 9 complete, 1284 tests Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). @@ -466,8 +466,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`) 2. ~~Page renderer — generic renderer tested in isolation~~ ✅ (`32f54c7`) 3. ~~Wire simple pages — Home, Content (x4), Contact, Error~~ ✅ -4. **Next →** Wire shop pages — Collection, PDP, Cart, Search -5. Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor +4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅ +5. **Next →** Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor 6. Admin editor — page list + block management (reorder, add, remove, duplicate, save) 7. Admin editor — inline block settings editing 8. Live preview — split layout with real-time preview diff --git a/docs/plans/page-builder.md b/docs/plans/page-builder.md index 549044f..a944d7d 100644 --- a/docs/plans/page-builder.md +++ b/docs/plans/page-builder.md @@ -1,6 +1,6 @@ # Page builder plan -Status: In progress (Stage 3 complete) +Status: In progress (Stage 4 complete) ## Context @@ -635,21 +635,19 @@ Each stage is a commit point. Tests pass, all pages work, nothing is broken. Pic --- -### Stage 4: Wire up shop pages (Collection, PDP, Cart, Search) +### Stage 4: Wire up shop pages (Collection, PDP, Cart, Search) ✅ -**Goal:** the complex shop pages switch to PageRenderer. These have URL-driven state, streams, JS hooks, and event handlers. +**Status:** Complete -- [ ] Update `Shop.Collection` — `Pages.get_page("collection")`, keep filter/sort handle_params -- [ ] Update `Shop.ProductShow` — `Pages.get_page("pdp")`, keep variant selection in handle_params, product_hero receives computed data -- [ ] Update `Shop.Cart` — `Pages.get_page("cart")`, cart events still handled by CartHook -- [ ] Update `Shop.Search` — `Pages.get_page("search")`, keep search handle_params -- [ ] Page-level CSS: PDP layout rules (product_hero handles two-column internally) -- [ ] Verify JS hooks survive: gallery carousel, lightbox, collection filters -- [ ] Verify variant selection, cart add/remove, search all work - -**Commit:** `wire collection, PDP, cart, and search pages to page renderer` - -**Verify:** `mix test` passes, full manual walkthrough of product browsing → add to cart → search flow +- [x] Update `Shop.Collection` — `Pages.get_page("collection")`, keep filter/sort handle_params +- [x] Update `Shop.ProductShow` — `Pages.get_page("pdp")`, keep variant selection in handle_params, product_hero receives computed data. Related products + reviews loaded via block data loaders instead of manual queries. +- [x] Update `Shop.Cart` — `Pages.get_page("cart")`, cart events still handled by CartHook +- [x] Update `Shop.Search` — `Pages.get_page("search")`, keep search handle_params +- [x] Renderer `filter_bar` block updated with full collection filter bar (category pills with live navigation, sort dropdown with phx-change, CollectionFilters hook, noscript fallback) +- [x] Renderer `product_grid` block updated with dynamic show_category and empty state +- [x] Added `Phoenix.VerifiedRoutes` to PageRenderer for `~p` sigil support +- [x] Added `collection_path/2` helper and `page_main_class("collection")` to renderer +- [x] 1284 tests pass, all pages verified visually --- diff --git a/lib/berrypod_web/live/shop/cart.ex b/lib/berrypod_web/live/shop/cart.ex index 70b484d..f0a69b6 100644 --- a/lib/berrypod_web/live/shop/cart.ex +++ b/lib/berrypod_web/live/shop/cart.ex @@ -1,11 +1,12 @@ defmodule BerrypodWeb.Shop.Cart do use BerrypodWeb, :live_view - alias Berrypod.Cart + alias Berrypod.{Cart, Pages} @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, page_title: "Cart")} + page = Pages.get_page("cart") + {:ok, socket |> assign(:page_title, "Cart") |> assign(:page, page)} end @impl true @@ -13,7 +14,7 @@ defmodule BerrypodWeb.Shop.Cart do assigns = assign(assigns, :cart_page_subtotal, Cart.calculate_subtotal(assigns.cart_items)) ~H""" - + """ end end diff --git a/lib/berrypod_web/live/shop/collection.ex b/lib/berrypod_web/live/shop/collection.ex index 66fdbb5..6a0726e 100644 --- a/lib/berrypod_web/live/shop/collection.ex +++ b/lib/berrypod_web/live/shop/collection.ex @@ -1,7 +1,7 @@ defmodule BerrypodWeb.Shop.Collection do use BerrypodWeb, :live_view - alias Berrypod.Products + alias Berrypod.{Pages, Products} @sort_options [ {"featured", "Featured"}, @@ -14,8 +14,11 @@ defmodule BerrypodWeb.Shop.Collection do @impl true def mount(_params, _session, socket) do + page = Pages.get_page("collection") + socket = socket + |> assign(:page, page) |> assign(:sort_options, @sort_options) |> assign(:current_sort, "featured") @@ -81,113 +84,10 @@ defmodule BerrypodWeb.Shop.Collection do defp collection_description("Sale"), do: "Browse our current sale items." defp collection_description(title), do: "Browse our #{String.downcase(title)} collection." - defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" - defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" - @impl true def render(assigns) do ~H""" - <.shop_layout {layout_assigns(assigns)} active_page="collection"> -
- <.collection_header - title={@collection_title} - product_count={length(@products)} - /> - -
- <.collection_filter_bar - categories={@categories} - current_slug={ - case @current_category do - :sale -> "sale" - nil -> nil - cat -> cat.slug - end - } - sort_options={@sort_options} - current_sort={@current_sort} - /> - - <.product_grid theme_settings={@theme_settings}> - <%= for product <- @products do %> - <.product_card - product={product} - theme_settings={@theme_settings} - mode={@mode} - variant={:default} - show_category={@current_category in [nil, :sale]} - /> - <% end %> - - - <%= if @products == [] do %> -
-

No products found in this collection.

- <.link navigate={~p"/collections/all"} class="collection-empty-link"> - View all products - -
- <% end %> -
-
- - """ - end - - defp collection_filter_bar(assigns) do - ~H""" -
- - -
- <.shop_select - name="sort" - options={@sort_options} - selected={@current_sort} - aria-label="Sort products" - /> - -
-
+ """ end end diff --git a/lib/berrypod_web/live/shop/product_show.ex b/lib/berrypod_web/live/shop/product_show.ex index 81646f5..b2ab828 100644 --- a/lib/berrypod_web/live/shop/product_show.ex +++ b/lib/berrypod_web/live/shop/product_show.ex @@ -1,7 +1,7 @@ defmodule BerrypodWeb.Shop.ProductShow do use BerrypodWeb, :live_view - alias Berrypod.{Analytics, Cart} + alias Berrypod.{Analytics, Cart, Pages} alias Berrypod.Images.Optimizer alias Berrypod.Products alias Berrypod.Products.{Product, ProductImage} @@ -13,13 +13,6 @@ defmodule BerrypodWeb.Shop.ProductShow do {:ok, push_navigate(socket, to: ~p"/collections/all")} product -> - related_products = - Products.list_visible_products( - category: product.category, - limit: 4, - exclude: product.id - ) - all_images = (product.images || []) |> Enum.sort_by(& &1.position) @@ -48,6 +41,8 @@ defmodule BerrypodWeb.Shop.ProductShow do og_url = base <> "/products/#{slug}" og_image = og_image_url(all_images) + page = Pages.get_page("pdp") + socket = socket |> assign(:page_title, product.title) @@ -58,12 +53,15 @@ defmodule BerrypodWeb.Shop.ProductShow do |> assign(:json_ld, product_json_ld(product, og_url, og_image, base)) |> assign(:product, product) |> assign(:all_images, all_images) - |> assign(:related_products, related_products) |> assign(:quantity, 1) |> assign(:option_types, option_types) |> assign(:variants, variants) + |> assign(:page, page) - {:ok, socket} + # Block data loaders (related_products, reviews) run after product is assigned + extra = Pages.load_block_data(page.blocks, socket.assigns) + + {:ok, assign(socket, extra)} end end @@ -269,7 +267,7 @@ defmodule BerrypodWeb.Shop.ProductShow do @impl true def render(assigns) do ~H""" - + """ end diff --git a/lib/berrypod_web/live/shop/search.ex b/lib/berrypod_web/live/shop/search.ex index aac8597..3d20534 100644 --- a/lib/berrypod_web/live/shop/search.ex +++ b/lib/berrypod_web/live/shop/search.ex @@ -1,11 +1,12 @@ defmodule BerrypodWeb.Shop.Search do use BerrypodWeb, :live_view - alias Berrypod.Search + alias Berrypod.{Pages, Search} @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :page_title, "Search")} + page = Pages.get_page("search") + {:ok, socket |> assign(:page_title, "Search") |> assign(:page, page)} end @impl true @@ -27,49 +28,7 @@ defmodule BerrypodWeb.Shop.Search do @impl true def render(assigns) do ~H""" - <.shop_layout {layout_assigns(assigns)} active_page="search"> -
- <.page_title text="Search" /> - -
- - -
- - <%= if @search_page_results != [] do %> -

- {length(@search_page_results)} {if length(@search_page_results) == 1, - do: "result", - else: "results"} for "{@search_page_query}" -

- <.product_grid theme_settings={@theme_settings}> - <%= for product <- @search_page_results do %> - <.product_card - product={product} - theme_settings={@theme_settings} - mode={@mode} - variant={:default} - /> - <% end %> - - <% else %> - <%= if @search_page_query != "" do %> -
-

No products found for "{@search_page_query}"

- <.link navigate="/collections/all" class="collection-empty-link"> - Browse all products - -
- <% end %> - <% end %> -
- + """ end end diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index a84554c..ceadb7e 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -10,6 +10,11 @@ defmodule BerrypodWeb.PageRenderer do use Phoenix.Component use BerrypodWeb.ShopComponents + use Phoenix.VerifiedRoutes, + endpoint: BerrypodWeb.Endpoint, + router: BerrypodWeb.Router, + statics: BerrypodWeb.static_paths() + alias Berrypod.Cart # ── Public API ────────────────────────────────────────────────── @@ -263,14 +268,85 @@ defmodule BerrypodWeb.PageRenderer do 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"""
- <.filter_bar categories={assigns[:categories] || []} /> +
+ + +
+ <.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}> @@ -280,10 +356,19 @@ defmodule BerrypodWeb.PageRenderer do theme_settings={@theme_settings} mode={@mode} variant={:default} - show_category={true} + 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 @@ -722,6 +807,7 @@ defmodule BerrypodWeb.PageRenderer do 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" @@ -767,6 +853,9 @@ defmodule BerrypodWeb.PageRenderer do defp breadcrumb_items(_), do: [] + defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" + defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" + # Reuse from PageTemplates def format_order_status("unfulfilled"), do: "Being prepared" def format_order_status("submitted"), do: "Sent to printer"