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"""
-
-
-
-
-
+
"""
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] || []} />
+
+
+
+
+
"""
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"