38 KiB
Page builder plan
Status: Complete (all stages)
Context
Pages are currently hardcoded as .heex templates in lib/berrypod_web/components/page_templates/. Each page's layout (which blocks appear, their order, their content) is baked into the template files. To customise anything — hero text, section order, adding/removing blocks — requires a code change.
This feature makes page layouts database-driven. Every page becomes a list of blocks stored as JSON. An admin editor lets you add, remove, reorder, and edit blocks on any page. The shop renders pages from these definitions, cached in ETS for speed.
Design principles
- Flexible: any block on any page (where data requirements allow), freely reorderable, no artificial constraints
- Scalable: one generic render function — adding new block types or pages needs no renderer changes
- Efficient: ETS-cached page definitions, data loaders only fire when their block is on the page
- Idiomatic: GenServer + ETS (same as CSSCache), Ecto schemas, Phoenix LiveView patterns
- Intuitive: mobile-first editor, flat block list, move up/down, inline edit, "reset to defaults"
Architecture
Generic renderer — one function for all pages
No page-specific render functions. One render_page/1 renders ALL pages:
def render_page(assigns) do
~H"""
<.shop_layout {layout_assigns(assigns)} active_page={@page.slug}>
<main id="main-content" class={page_main_class(@page.slug)}>
<%= for block <- @page.blocks do %>
<.render_block block={block} {assigns} />
<% end %>
</main>
</.shop_layout>
"""
end
Every page is a flat list of blocks rendered in order. page_main_class/1 adds a CSS class (e.g. "pdp-main", "contact-main") for page-level styling. Each block component emits its own CSS class. Page-level CSS handles multi-column layouts where needed.
Composite blocks for coupled content
Content that's inherently paired shares a single block — the internal layout is handled by the block component, not the renderer.
| Composite block | Combines | Page |
|---|---|---|
product_hero |
gallery + info + variants + quantity + add-to-cart | PDP |
checkout_result |
status icon + items + address | Checkout success |
order_card |
header + items + totals (in a loop) | Orders |
order_detail_card |
header + tracking + items + address | Order detail |
Smaller blocks remain individual and freely reorderable (hero, trust_badges, reviews, related_products, product_details, breadcrumb, etc.).
No fixed blocks — full freedom + reset
Everything is freely movable and removable. If a user makes a mess, "Reset to defaults" restores the original layout. This is more powerful and simpler than guarding against every edge case.
Block portability
Portable blocks (work on any page, allowed_on: :all):
- hero, image_text, featured_products, category_nav, newsletter_card, social_links_card, info_card, trust_badges, reviews_section
Page-specific blocks (need page-context data or event handlers):
- product_hero, product_details, breadcrumb, related_products → PDP only
- collection_header, filter_bar, product_grid → collection only
- cart_items, order_summary → cart only
- contact_form, order_tracking_card → contact only
- content_body → content pages (about, delivery, privacy, terms)
- checkout_result → checkout_success only
- order_card → orders only
- order_detail_card → order_detail only
allowed_on is enforced in the editor — you can't add a block that depends on data/events the page doesn't provide.
Dynamic data loading via block data loaders
Each block type can optionally declare a data loader — a function that takes current assigns + block settings and returns extra data to merge into assigns. The LiveView collects these from all blocks on the page and runs them at mount/handle_params.
# In BlockTypes registry
"featured_products" => %{
data_loader: fn assigns, settings ->
limit = Map.get(settings, "product_count", 8)
%{products: Products.list_visible_products(limit: limit)}
end
}
# In the LiveView
page = Pages.get_page("home")
extra = Pages.load_block_data(page.blocks, socket.assigns)
# => %{products: [%Product{}, ...]}
Three layers of data availability:
- Global assigns (from hooks — always available): theme_settings, categories, cart_items, cart_count, mode, is_admin, search_query, etc.
- Page-context data (loaded by the LiveView based on route): product for PDP, collection for Collection, order for checkout, etc. LiveViews stay page-specific — they handle route params, events, and page-context data.
- Block-loaded data (from data loaders, loaded dynamically): featured products, related products, reviews, legal content, etc. Only loaded when a block that needs them is on the page.
If someone adds a featured_products block to the contact page, its data loader fires and loads products. If the block is removed, the query doesn't run.
Blocks without data loaders use either global assigns or page-context data that the LiveView has already loaded.
URL path → page slug mapping
Each LiveView maps to exactly one page slug. The LiveView hardcodes its slug and calls Pages.get_page/1.
| URL path | LiveView | Page slug |
|---|---|---|
/ |
Shop.Home |
"home" |
/about |
Shop.Content (:about) |
"about" |
/delivery |
Shop.Content (:delivery) |
"delivery" |
/privacy |
Shop.Content (:privacy) |
"privacy" |
/terms |
Shop.Content (:terms) |
"terms" |
/contact |
Shop.Contact |
"contact" |
/collections/:slug |
Shop.Collection |
"collection" |
/products/:id |
Shop.ProductShow |
"pdp" |
/cart |
Shop.Cart |
"cart" |
/search |
Shop.Search |
"search" |
/checkout/success |
Shop.CheckoutSuccess |
"checkout_success" |
/orders |
Shop.Orders |
"orders" |
/orders/:order_number |
Shop.OrderDetail |
"order_detail" |
Shop.Content handles 4 slugs via live_action: Pages.get_page(to_string(socket.assigns.live_action)).
Data model
Single pages table
pages
├── id binary_id (PK)
├── slug string (unique) — "home", "about", "contact", "delivery", "privacy",
│ "terms", "pdp", "collection", "cart", "search",
│ "checkout_success", "orders", "order_detail", "error"
├── title string — "Home page"
├── blocks map (JSON) — ordered list of block definitions
└── timestamps
Each block in the JSON array:
{"id": "blk_abc123", "type": "hero", "settings": {"title": "...", "description": "..."}}
No separate page_blocks table — the block list is small (3-12 per page) and always loaded as a unit. A single JSON column is simpler, avoids N+1 queries, and makes reordering trivial.
Block type registry
Berrypod.Pages.BlockTypes — defines all block types. Each has:
name— display nameicon— heroicon nameallowed_on—:allor list of page slugssettings_schema— list of editable settings with types/defaultsdata_loader— optionalfn(assigns, settings) -> %{key: value}
Key block types with their settings:
# Portable blocks (allowed_on: :all)
"hero" => %{
name: "Hero banner", icon: "hero-megaphone",
settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "cta_text", label: "Button text", type: :text, default: ""},
%{key: "cta_href", label: "Button link", type: :text, default: ""},
%{key: "variant", label: "Style", type: :select, options: ~w(default page sunken error), default: "default"}
]
}
"featured_products" => %{
name: "Featured products", icon: "hero-star",
data_loader: fn _assigns, settings ->
%{products: Products.list_visible_products(limit: settings["product_count"] || 8)}
end,
settings_schema: [
%{key: "title", label: "Title", type: :text, default: "Featured products"},
%{key: "product_count", label: "Number of products", type: :number, default: 8}
]
}
"image_text" => %{
name: "Image + text", icon: "hero-photo",
settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "image_url", label: "Image URL", type: :text, default: ""},
%{key: "link_text", label: "Link text", type: :text, default: ""},
%{key: "link_href", label: "Link URL", type: :text, default: ""}
]
}
"category_nav" => %{name: "Category navigation", icon: "hero-squares-2x2", settings_schema: []}
"newsletter_card" => %{name: "Newsletter signup", icon: "hero-envelope", settings_schema: []}
"social_links_card" => %{name: "Social links", icon: "hero-share", settings_schema: []}
"info_card" => %{name: "Info card", icon: "hero-information-circle",
settings_schema: [%{key: "title", label: "Title", type: :text, default: ""},
%{key: "items", label: "Items", type: :json, default: []}]}
"trust_badges" => %{name: "Trust badges", icon: "hero-shield-check", settings_schema: []}
"reviews_section" => %{name: "Customer reviews", icon: "hero-chat-bubble-left-right", settings_schema: []}
# PDP blocks (allowed_on: ["pdp"])
"product_hero" => %{
name: "Product hero", icon: "hero-cube",
settings_schema: [] # no editable settings — driven entirely by product data
}
"breadcrumb" => %{name: "Breadcrumb", icon: "hero-chevron-right", settings_schema: []}
"product_details" => %{name: "Product details", icon: "hero-document-text", settings_schema: []}
"related_products" => %{
name: "Related products", icon: "hero-squares-plus",
data_loader: fn assigns, _settings ->
case assigns[:product] do
%{category: cat, id: id} ->
%{related_products: Products.list_visible_products(category: cat, limit: 4, exclude: id)}
_ -> %{related_products: []}
end
end
}
# Collection blocks (allowed_on: ["collection"])
"collection_header" => %{name: "Collection header", icon: "hero-tag", settings_schema: []}
"filter_bar" => %{name: "Filter bar", icon: "hero-funnel", settings_schema: []}
"product_grid" => %{name: "Product grid", icon: "hero-squares-2x2", settings_schema: []}
# Cart blocks (allowed_on: ["cart"])
"cart_items" => %{name: "Cart items", icon: "hero-shopping-cart", settings_schema: []}
"order_summary" => %{name: "Order summary", icon: "hero-calculator", settings_schema: []}
# Contact blocks (allowed_on: ["contact"])
"contact_form" => %{name: "Contact form", icon: "hero-envelope",
settings_schema: [%{key: "email", label: "Email", type: :text, default: "hello@example.com"}]}
"order_tracking_card" => %{name: "Order tracking", icon: "hero-truck", settings_schema: []}
# Content blocks (allowed_on: ["about", "delivery", "privacy", "terms"])
"content_body" => %{
name: "Page content", icon: "hero-document-text",
settings_schema: [
%{key: "image_src", label: "Image", type: :text, default: ""},
%{key: "image_alt", label: "Image alt text", type: :text, default: ""}
]
}
# Checkout success (allowed_on: ["checkout_success"])
"checkout_result" => %{name: "Checkout result", icon: "hero-check-circle", settings_schema: []}
# Orders (allowed_on: ["orders"])
"order_card" => %{name: "Order cards", icon: "hero-clipboard-document-list", settings_schema: []}
# Order detail (allowed_on: ["order_detail"])
"order_detail_card" => %{name: "Order detail", icon: "hero-clipboard-document", settings_schema: []}
# Search (allowed_on: ["search"])
"search_results" => %{name: "Search results", icon: "hero-magnifying-glass", settings_schema: []}
Default page definitions
Berrypod.Pages.Defaults — returns the default block list for each page, matching the current static templates exactly. When Pages.get_page("home") finds nothing in the DB, it returns defaults. First edit saves to DB.
Home:
[hero, category_nav, featured_products, image_text]
PDP:
[breadcrumb, product_hero, trust_badges, product_details, reviews_section, related_products]
Collection:
[collection_header, filter_bar, product_grid]
Cart:
[cart_items, order_summary]
Contact:
[hero, contact_form, order_tracking_card, info_card, newsletter_card, social_links_card]
About:
[hero, content_body]
Delivery / Privacy / Terms:
[hero, content_body]
Checkout success:
[checkout_result]
Orders:
[order_card]
Order detail:
[order_detail_card]
Error:
[hero, featured_products]
Search:
[search_results] # page-specific block
Caching
Berrypod.Pages.PageCache — GenServer that owns a :page_cache ETS table. Same pattern as Berrypod.Theme.CSSCache.
get(slug)→{:ok, page_data}|:missput(slug, page_data)→:okinvalidate(slug)→:okwarm()→ loads all pages from DB into ETS on startup
Cache lookup is O(1). Invalidated on save. No TTL needed.
How LiveViews change
# Before (Shop.Home)
def mount(_params, _session, socket) do
products = Products.list_visible_products(limit: 8)
{:ok, assign(socket, products: products)}
end
def render(assigns), do: ~H"<PageTemplates.home {assigns} />"
# After (Shop.Home)
def mount(_params, _session, socket) do
page = Pages.get_page("home")
extra = Pages.load_block_data(page.blocks, socket.assigns)
{:ok, socket |> assign(:page, page) |> assign(extra)}
end
def render(assigns), do: ~H"<PageRenderer.render_page {assigns} />"
For complex pages (PDP, Collection), the LiveView still loads page-context data and handles events. load_block_data/2 adds any extra data needed by portable blocks.
Theme editor preview integration
The theme editor (lib/berrypod_web/live/admin/theme/index.ex) currently renders preview pages by calling PageTemplates.home, PageTemplates.pdp, etc directly, with mock data from PreviewData. After the page builder, it switches to PageRenderer.render_page/1 like everything else.
How it works:
- Theme editor loads the page definition via
Pages.get_page(slug)for the current preview page - Passes
mode: :preview— components already check this to disable navigation and make buttons inert - Block data loaders run with preview-aware data:
featured_productsloader → usesPreviewData.products()(real products if they exist, mock otherwise)related_productsloader → usesPreviewData.products()slicedcontent_bodyloader → usesPreviewData.about_content()etc for legal pages- Cart blocks → uses
PreviewData.cart_items()(always mock) - Reviews → uses
PreviewData.reviews()(always mock)
- The existing
preview_page/1dispatch function is replaced by loading different page slugs
Preview data strategy (unchanged from current system):
- Products/categories: real data if available, mock data otherwise (via
PreviewData.has_real_products?/0) - Cart, reviews, testimonials: always mock (session-specific or editorial content)
- Legal page content: preview text from
PreviewData - No fallback to preview data on the live shop — if something breaks, show the error page
What changes in index.ex:
- Remove the 10 individual
preview_page/1function clauses - Replace with: load
Pages.get_page(page_slug), runPages.load_block_data/2with preview assigns, callPageRenderer.render_page/1 - The
preview_assigns/1helper still works — mergesmode: :previewand preview data into assigns - Page switcher buttons unchanged — just update
@preview_pageassign
Error page rendering
Error pages (404, 500) are rendered by ErrorHTML (a controller, not a LiveView). They currently load theme settings, CSS, and products with safe_load wrappers to handle DB failures gracefully.
After the page builder, error pages use Pages.get_page("error") to get the block list, then render via PageRenderer. The safe_load pattern stays — if the page cache or DB is unavailable, fall back to hardcoded defaults. This is the one page where graceful degradation matters, since errors can happen when the system is in a bad state.
CSS-driven page layout
Each <main> gets a page-specific class. Each block component emits its own class. Page-level CSS handles multi-column layouts:
/* Contact: sidebar layout on desktop */
.contact-main {
@media (min-width: 768px) {
display: grid;
grid-template-columns: 1fr 1fr;
& > .block-hero { grid-column: 1 / -1; }
& > .block-contact-form { grid-column: 1; }
& > .block-sidebar-card { grid-column: 2; }
}
}
Blocks are full-width by default. CSS creates multi-column layouts where the existing design calls for it. This keeps the renderer generic while preserving the current page layouts.
Admin editor UX
Mobile-first, standard admin layout (sidebar nav). No drag-and-drop — deliberate button-based reordering for accessibility and robustness.
Page list (/admin/pages)
Grouped cards: Marketing (Home, About, Contact), Legal (Delivery, Privacy, Terms), Shop (Collection, PDP, Cart, Search), Order (Checkout success, Orders, Order detail), System (Error). Click to edit.
Page editor (/admin/pages/:slug)
Page title at top, then an ordered list of block cards.
Each block card shows:
- Position number (1, 2, 3...) — always visible so the user can see the order at a glance
- Block icon + name (e.g. "Hero banner")
- Up/down arrow buttons — large touch targets, one press = one position move
- Up button disabled (visually +
aria-disabled) when block is first - Down button disabled when block is last
- Up button disabled (visually +
- Edit button → expands inline settings form
- Remove button → removes block from the list
Reordering UX:
- Each move is a deliberate button press — no accidental drags, no imprecise gestures
- After a move, the block briefly highlights and the list reflows. Position numbers update.
- Focus follows the moved block so the user can keep pressing to move it further
- Keyboard: Tab through the list, Enter/Space to activate buttons
- Screen reader: each card announced as "Hero banner, position 1 of 4". Move buttons labelled "Move Hero banner up". After moving, ARIA live region announces "Hero banner moved to position 2"
- For pages with 3-8 blocks, up/down arrows are plenty fast — no need for "move to position X" dropdowns
Page-level actions:
- "+ Add block" button → picker showing portable blocks + page-specific blocks allowed on this page
- "Save" button → persist to DB, invalidate cache, flash confirmation
- "Reset to defaults" button → restores original block list (with confirmation)
- All changes are local until Save — nothing on the live site changes until the user explicitly saves. Removes the anxiety of "what if I break something".
Unsaved changes guard
Track @dirty assign (boolean, starts false). Set true on any block move, edit, add, or remove. Reset to false on save or reset to defaults.
- Browser close / external navigation: a
phx-hook(DirtyGuard) toggleswindow.onbeforeunloadbased on@dirty. Browser shows standard "You have unsaved changes" dialog. - LiveView navigation (admin sidebar links, back button): intercept via
phx-clickon nav links when dirty, show a simple "You have unsaved changes — discard or stay?" confirmation before allowing navigation.
Block settings form (inline)
Expands below the block card when Edit is clicked:
- Generated from
settings_schema— text inputs, textareas, selects, number inputs - Cancel / Apply buttons
- Changes are local until "Save" is clicked for the whole page
Live preview
Split layout on desktop (same pattern as the theme editor in lib/berrypod_web/live/admin/theme/index.ex):
- Left panel: block list + editing controls (scrollable)
- Right panel: live preview iframe showing the page rendered with current (unsaved) block state
- Preview updates on every block change — move, edit, add, remove — without saving to DB
- On mobile: toggle button switches between "Edit" and "Preview" views (no split)
How it works:
- The editor LiveView holds the working block list in
@blocks(a list of maps, not yet persisted) - Preview is rendered via a dedicated route (e.g.
/admin/pages/:slug/preview) that accepts block definitions via query params or a temporary ETS entry keyed by session - The preview route renders
PageRenderer.render_page/1with the working blocks + appropriate data loaders - On each edit, the editor updates the preview by pushing the new block state
This mirrors how the theme editor already works — edit on the left, see results on the right, save when ready.
Undo / redo
Every mutation (move, add, remove, edit settings) pushes state onto a history stack:
# In the editor LiveView assigns
@history [] # list of previous block states (newest first)
@future [] # list of "undone" states for redo
@blocks [...] # current working state
On each mutation:
- Push current
@blocksonto@history - Clear
@future(new mutation invalidates redo chain) - Apply the mutation to get new
@blocks
Undo: pop from @history, push current @blocks onto @future.
Redo: pop from @future, push current @blocks onto @history.
Controls:
- Undo/redo buttons in the editor toolbar (disabled when stack is empty)
- Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo) via a JS hook
- History is capped at ~50 steps to avoid memory bloat
Extra polish
- Block duplication: "Duplicate" button on each block card — copies the block (with new ID) and inserts it directly below. Handy for creating variations.
- Block search in picker: when the "+ Add block" picker opens, a search/filter input at the top lets you type to narrow the list. Useful as more block types are added.
- Smooth animations: CSS transitions on block reordering (translate transform), expand/collapse of settings forms, and block add/remove. Keeps the editor feeling responsive and polished.
- Block collapse/expand: each block card can be collapsed to just icon + name (one-line), or expanded to show settings. Default is collapsed. Reduces visual noise on pages with many blocks.
Files to create
| File | Purpose |
|---|---|
priv/repo/migrations/*_create_pages.exs |
Migration |
lib/berrypod/pages.ex |
Context (CRUD + cache integration + load_block_data) |
lib/berrypod/pages/page.ex |
Ecto schema |
lib/berrypod/pages/block_types.ex |
Block type registry with data loaders |
lib/berrypod/pages/defaults.ex |
Default block definitions for all 14 pages |
lib/berrypod/pages/page_cache.ex |
GenServer + ETS cache |
lib/berrypod_web/page_renderer.ex |
Generic renderer (one render function for all pages) |
lib/berrypod_web/live/admin/pages.ex |
Admin page editor LiveView |
test/berrypod/pages_test.exs |
Context + data loader tests |
test/berrypod_web/page_renderer_test.exs |
Renderer tests |
test/berrypod_web/live/admin/pages_test.exs |
Editor integration tests |
Files to modify
| File | Change |
|---|---|
lib/berrypod/application.ex |
Add PageCache to supervision tree |
lib/berrypod_web/router.ex |
Add /admin/pages and /admin/pages/:slug routes |
lib/berrypod_web/components/layouts/admin.html.heex |
Add "Pages" nav link |
assets/css/admin/components.css |
Page editor styles |
assets/css/shop/pages.css (or similar) |
Page-level CSS grid rules for block layout |
lib/berrypod_web/live/shop/home.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/content.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/contact.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/product_show.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/collection.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/cart.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/search.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/orders.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/order_detail.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/shop/checkout_success.ex |
Use PageRenderer + load_block_data |
lib/berrypod_web/live/admin/theme/index.ex |
Use PageRenderer for preview |
lib/berrypod_web/controllers/error_html.ex |
Use PageRenderer with safe_load fallback |
Existing code to reuse
Berrypod.Theme.CSSCache(lib/berrypod/theme/css_cache.ex) — GenServer + ETS pattern for PageCacheBerrypodWeb.ShopComponents.*— all existing components called by block renderersBerrypodWeb.PageTemplates(lib/berrypod_web/components/page_templates.ex) —layout_assigns/1,format_order_status/1reused in rendererBerrypod.Theme.PreviewData(lib/berrypod/theme/preview_data.ex) — content generatorsBerrypod.LegalPages(lib/berrypod/legal_pages.ex) — dynamic legal content- Admin CSS (
assets/css/admin/components.css) — existing card/form styles
Gotchas and edge cases
Things the audit uncovered that the implementation needs to handle:
JS hooks must survive block reordering
Several shop components rely on JS hooks (phx-hook): ProductImageScroll (gallery carousel), Lightbox (PDP image modal), CartPersist (session sync), SearchModal (Cmd+K), CollectionFilters (mobile pill scroll). When blocks are reordered in the editor preview or on the live site, hooks attached to block DOM elements must not lose state. The renderer should use stable DOM IDs per block (the block id field) so LiveView can diff correctly.
SEO meta tags are set per LiveView, not per block
Each LiveView currently sets page_title, og_url, og_image, meta_description, and json_ld in mount/handle_params. These stay in the LiveView — they're not block concerns. The page builder doesn't change how SEO metadata works. LiveViews still own their meta tags.
PDP variant selection is URL-driven
Variant selection on the PDP works via URL params (?Size=M&Color=Red). handle_params recomputes available options, filters gallery images by colour, and resolves the selected variant. This logic stays in Shop.ProductShow — the product_hero composite block just receives the computed data. The block doesn't need to know about URL params.
Cart events are handled by hooks, not LiveViews
CartHook attaches event handlers via attach_hook(:cart_events, :handle_event, ...) that intercept cart events (add, remove, update quantity, open/close drawer) before they reach the LiveView. Same for SearchHook. These hooks work regardless of which blocks are on the page — they're global. No changes needed.
Checkout success uses PubSub for real-time order status
Shop.CheckoutSuccess subscribes to "order:#{order.id}:status" and handles :order_paid messages. This stays in the LiveView. The checkout_result block just renders whatever order state it's given.
Contact page has stateful order tracking
Shop.Contact tracks tracking_state (:idle → :sent → :not_found) and handles events for the order lookup flow. This state management stays in the LiveView. The order_tracking_card block receives tracking_state as an assign.
Error page must degrade gracefully
ErrorHTML wraps every data load in safe_load (try/rescue). The page builder's Pages.get_page("error") call must also be wrapped — if the ETS cache and DB are both down, fall back to a hardcoded minimal error page. This is the only page that needs this treatment.
mode: :preview already handled in components
Shop components already check @mode to decide between real navigation (.link navigate) and inert buttons (phx-click="change_preview_page"). The page builder's live preview inherits this — render blocks with mode: :preview and components behave correctly. No changes needed in shop components.
Responsive images need the optimiser pipeline
responsive_image/1 generates <picture> elements with AVIF/WebP/JPEG sources at computed widths. Image URLs follow the pattern /image_cache/{id}-{width}.{format}. The image_text block's image_url setting needs to work with this — either accept a media ID (and use the optimiser) or accept a raw URL (for external images). Start with raw URLs for simplicity.
Flash messages are page-level, not block-level
shop_flash_group renders flash messages from the socket. This stays in the layout wrapper (shop_layout), not in any block. No changes needed.
Implementation stages
Each stage is a commit point. Tests pass, all pages work, nothing is broken. Pick up from wherever you left off.
Stage 1: Foundation — data model, cache, block registry ✅
Status: Complete (commit 35f96e4)
- Migration, schema, block types registry (26 types), defaults (14 pages), page cache, context
- 34 tests covering context CRUD, block types, defaults completeness, data loaders, cache
Stage 2: Page renderer ✅
Status: Complete (commit 32f54c7)
BerrypodWeb.PageRenderer—render_page/1wraps in shop_layout,render_block/1dispatches all 26 block types to existing shop components- Each block is self-contained HEEx (CSS grid handles multi-column layouts, not split tags)
page_main_class/1,format_order_status/1,block_assigns/1(preserves__changed__for Phoenix assigns)- 18 renderer tests covering all 14 pages
- Key lessons: block settings use string keys (JSON), components expect atom keys — conversion needed in render_block; error page hero reads title/description from assigns as overrides
Stage 3: Wire up simple pages (Home, Content, Contact, Error) ✅
Status: Complete
- Update
Shop.Home—Pages.get_page("home")+load_block_data/2+ PageRenderer - Update
Shop.Content—Pages.get_page(live_action)for about/delivery/privacy/terms - Update
Shop.Contact—Pages.get_page("contact"), keep tracking_state event handlers - Update
ErrorHTML—Pages.get_page("error")with safe_load fallback - Page-level CSS: contact page grid layout
- SEO meta tags still set correctly in each LiveView
- All pages visually identical to before
Stage 4: Wire up shop pages (Collection, PDP, Cart, Search) ✅
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. Related products + reviews loaded via block data loaders instead of manual queries. - Update
Shop.Cart—Pages.get_page("cart"), cart events still handled by CartHook - Update
Shop.Search—Pages.get_page("search"), keep search handle_params - Renderer
filter_barblock updated with full collection filter bar (category pills with live navigation, sort dropdown with phx-change, CollectionFilters hook, noscript fallback) - Renderer
product_gridblock updated with dynamic show_category and empty state - Added
Phoenix.VerifiedRoutesto PageRenderer for~psigil support - Added
collection_path/2helper andpage_main_class("collection")to renderer - 1284 tests pass, all pages verified visually
Stage 5: Wire up order pages + theme preview ✅
Status: Complete
- Update
Shop.CheckoutSuccess—Pages.get_page("checkout_success"), keep PubSub subscription - Update
Shop.Orders—Pages.get_page("orders") - Update
Shop.OrderDetail—Pages.get_page("order_detail") - Update theme editor — unified
preview_page/1withPages.get_page(slug)+load_block_data/2+ PageRenderer (10 clauses → 1 + page-context helpers) - Removed
PageTemplatesmodule + 10.heextemplate files (zero references remain) layout_assigns/1already lives inShopComponents.Layout— no move needed- 1284 tests pass,
mix precommitclean
Stage 6: Admin editor — page list + block management ✅
Status: Complete (commit 660fda9)
Admin.Pages.Index— page list with 5 groups (Marketing, Legal, Shop, Orders, System), icons, block countsAdmin.Pages.Editor— block cards with position numbers, icons, names- Routes:
/admin/pagesand/admin/pages/:slug, "Pages" nav link in admin sidebar - Move up/down with ARIA live region announcements, disabled at edges
- Remove block (with confirmation), duplicate block (copies settings)
- "+ Add block" picker with search/filter, enforces
allowed_onper page - Save → persist to DB, invalidate cache, flash. Reset to defaults with confirmation
@dirtyflag + DirtyGuard JS hook for unsaved changes warning- Admin CSS for page list, block cards, block picker modal
- 25 integration tests covering list, reorder, add, remove, duplicate, reset, save, dirty flag
- Regenerated admin icons (81 rules) with
@layer adminwrapping fix in mix task - Added
:keyto renderer block loop for correct LiveView diffing - 1309 tests pass,
mix precommitclean
Stage 7: Admin editor — inline block editing ✅
Status: Complete
- Inline settings form generated from
settings_schema— text, textarea, select, number, json (read-only) - Block expand/collapse with
@expandedMapSet, edit button (cog icon) on blocks with settings phx-changeupdates working state instantly, no Cancel/Apply (page-level Save/Reset handles it)- Number type coercion (form params → integers), schema defaults merged for missing keys
- Full ARIA:
aria-expanded,aria-controls, live region announcements, unique field IDs - Debouncing: 300ms on text/textarea, blur on select/number
- 11 new tests (36 total in pages_test), 1320 tests total,
mix precommitclean
Stage 8: Live preview
Goal: split layout with real-time preview as you edit.
- Split layout on desktop: block list left, preview right (same approach as theme editor)
- Preview renders PageRenderer with working (unsaved) block state via temporary ETS entry
- Preview updates on every block change without saving
- Preview toggle on mobile (edit/preview switch)
- CSS for split layout
Commit: add live preview to page editor
Verify: move a block, see it move in preview. Edit hero text, see it update.
Stage 9: Undo/redo + polish
Goal: professional-quality editor with undo/redo and smooth interactions.
@history/@futurestacks in editor LiveView- Undo/redo buttons in toolbar (disabled when stack empty)
- Ctrl+Z / Ctrl+Shift+Z via JS hook
- History capped at ~50 steps
- CSS transitions: block reorder animation, settings expand/collapse, add/remove
- Final integration tests
Commit: add undo/redo and polish to page editor
Verify: make 5 changes, undo 3, redo 1, save — correct state persisted
Verification checklist
Run after each stage to confirm nothing is broken:
mix test— all tests pass- Browse all shop pages — visually identical to before (until blocks are edited)
- Theme editor preview works for all page types
- Cart flow: add to cart, view cart, update quantity — all work
- PDP: variant selection, gallery, lightbox — all work
- Collection: filtering, sorting — all work
- Contact: order tracking form — works
- Search: results appear, product links work
- Error page: visit
/nonexistent— styled error page renders
Blocks with placeholder content (future features)
Several blocks currently render with hardcoded or preview data on the live shop. The page builder renders them as-is — their data loaders return the same placeholder content. Each becomes a real feature later, and the data loader is the only thing that changes.
| Block | Current state | Future feature | Page builder approach |
|---|---|---|---|
reviews_section |
PreviewData.reviews() on live PDP |
Reviews system: DB table, collection (manual/import/email request), moderation, display | Data loader returns PreviewData.reviews() for now. Swap to Reviews.list_approved(product_id) later. |
newsletter_card |
Static form, submit does nothing | Newsletter integration: Mailchimp/ConvertKit/Buttondown, or local subscribers table | Render form as-is. Wire phx-submit handler later. Quick win: store email in a subscribers table. |
contact_form |
Static form, submit does nothing | Contact form: send email via existing email system, or store in DB | Render form as-is. Quick win: add phx-submit that sends email (email system already exists). |
social_links_card |
Hardcoded default URLs | Social links settings: admin-configurable URLs | Block settings already support this — add URL fields to settings_schema. Can be done during page builder implementation. |
trust_badges |
Hardcoded "Made to order" / "Quality materials" | Admin-configurable badges | Block settings already support this — items in settings_schema. Editable once the page editor ships. |
announcement_bar |
Hardcoded sample message, visibility via theme setting | Admin-editable message | Add message to theme settings (small change, not a separate feature). |
info_card |
"Handy to know" items hardcoded in template | Admin-editable | Already has items in settings_schema. Editable once the page editor ships. |
hero / image_text |
Text hardcoded in templates | Admin-editable | Already have full settings_schema. Editable once the page editor ships. |
Key insight: most of these are already solved by the page builder itself (hero text, info card items, trust badges, social links). The only ones needing separate backend features are reviews, newsletter, and contact form — and even those work fine with placeholder content until the features are built.