berrypod/docs/plans/page-builder.md
jamey c69e51051f wire simple pages to PageRenderer (stage 3)
Home, Content (about/delivery/privacy/terms), Contact, and ErrorHTML
now render through the generic PageRenderer instead of hardcoded
templates. Block wrapper divs enable CSS grid targeting. Featured
products block supports layout/card_variant/columns settings for
different page contexts. Contact page uses CSS grid on data-block-type
attributes for two-column layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:29:20 +00:00

38 KiB

Page builder plan

Status: In progress (Stage 3 complete)

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:

  1. Global assigns (from hooks — always available): theme_settings, categories, cart_items, cart_count, mode, is_admin, search_query, etc.
  2. 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.
  3. 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 name
  • icon — heroicon name
  • allowed_on:all or list of page slugs
  • settings_schema — list of editable settings with types/defaults
  • data_loader — optional fn(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} | :miss
  • put(slug, page_data):ok
  • invalidate(slug):ok
  • warm() → 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_products loader → uses PreviewData.products() (real products if they exist, mock otherwise)
    • related_products loader → uses PreviewData.products() sliced
    • content_body loader → uses PreviewData.about_content() etc for legal pages
    • Cart blocks → uses PreviewData.cart_items() (always mock)
    • Reviews → uses PreviewData.reviews() (always mock)
  • The existing preview_page/1 dispatch 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/1 function clauses
  • Replace with: load Pages.get_page(page_slug), run Pages.load_block_data/2 with preview assigns, call PageRenderer.render_page/1
  • The preview_assigns/1 helper still works — merges mode: :preview and preview data into assigns
  • Page switcher buttons unchanged — just update @preview_page assign

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
  • 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) toggles window.onbeforeunload based on @dirty. Browser shows standard "You have unsaved changes" dialog.
  • LiveView navigation (admin sidebar links, back button): intercept via phx-click on 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/1 with 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:

  1. Push current @blocks onto @history
  2. Clear @future (new mutation invalidates redo chain)
  3. 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 PageCache
  • BerrypodWeb.ShopComponents.* — all existing components called by block renderers
  • BerrypodWeb.PageTemplates (lib/berrypod_web/components/page_templates.ex) — layout_assigns/1, format_order_status/1 reused in renderer
  • Berrypod.Theme.PreviewData (lib/berrypod/theme/preview_data.ex) — content generators
  • Berrypod.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.PageRendererrender_page/1 wraps in shop_layout, render_block/1 dispatches 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)

Goal: first real pages switch to PageRenderer. These are the simplest — no URL-driven state, no streams. Visually identical to before.

  • Update Shop.HomePages.get_page("home") + load_block_data/2 + PageRenderer
  • Update Shop.ContentPages.get_page(live_action) for about/delivery/privacy/terms
  • Update Shop.ContactPages.get_page("contact"), keep tracking_state event handlers
  • Update ErrorHTMLPages.get_page("error") with safe_load fallback
  • Page-level CSS: contact page grid layout
  • Ensure SEO meta tags still set correctly in each LiveView
  • Browse each page manually — must look identical

Commit: wire home, content, contact, and error pages to page renderer

Verify: mix test passes, browse all 6 pages (home, about, delivery, privacy, terms, contact), compare to screenshots/before state


Goal: the complex shop pages switch to PageRenderer. These have URL-driven state, streams, JS hooks, and event handlers.

  • Update Shop.CollectionPages.get_page("collection"), keep filter/sort handle_params
  • Update Shop.ProductShowPages.get_page("pdp"), keep variant selection in handle_params, product_hero receives computed data
  • Update Shop.CartPages.get_page("cart"), cart events still handled by CartHook
  • Update Shop.SearchPages.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


Stage 5: Wire up order pages + theme preview

Goal: remaining pages switch over. Theme editor uses PageRenderer. All old page templates are now unused.

  • Update Shop.CheckoutSuccessPages.get_page("checkout_success"), keep PubSub subscription
  • Update Shop.OrdersPages.get_page("orders")
  • Update Shop.OrderDetailPages.get_page("order_detail")
  • Update theme editor — replace 10 preview_page/1 clauses with Pages.get_page(slug) + load_block_data/2 + PageRenderer
  • Verify theme preview still works: page switching, CSS injection, mode: :preview
  • Remove old page templates (the .heex files) if no longer referenced
  • Move layout_assigns/1 and any shared helpers to PageRenderer or a shared module

Commit: wire order pages and theme preview to page renderer, remove old templates

Verify: mix test passes, theme editor preview works for all 10 pages, checkout flow works end to end


Stage 6: Admin editor — page list + block management

Goal: admin can see all pages and reorder/add/remove blocks. No inline editing yet. Save persists to DB.

  • Create Admin.Pages LiveView with :index and :edit live_actions
  • Routes: /admin/pages and /admin/pages/:slug
  • Add "Pages" nav link to admin sidebar
  • Page list: grouped cards (Marketing, Legal, Shop, Order, System)
  • Block list: ordered cards with position number, icon, name
  • Move up/down buttons with accessible UX (focus follows, ARIA live region, disabled at edges)
  • Remove block button
  • "+ Add block" picker showing allowed blocks for this page, with search/filter
  • Duplicate block button
  • "Reset to defaults" with confirmation
  • Save → persist to DB, invalidate cache, flash
  • @dirty flag + DirtyGuard hook for unsaved changes warning
  • Admin CSS for page editor
  • Integration tests: list pages, reorder, add, remove, duplicate, reset, save

Commit: add admin page editor with block reordering and management

Verify: mix test passes, can reorder blocks on home page, save, refresh shop — layout changed


Stage 7: Admin editor — inline block editing

Goal: admin can edit block settings (hero text, product count, etc). Full editing workflow complete.

  • Inline settings form generated from settings_schema
  • Form field types: text, textarea, select, number, json
  • Cancel/Apply on each block's settings form
  • Block collapse/expand (icon + name one-liner vs expanded card)
  • Integration tests: edit hero title, save, verify on shop

Commit: add inline block settings editing to page editor

Verify: mix test passes, edit home hero title in admin, save, see it on the shop


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 / @future stacks 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:

  1. mix test — all tests pass
  2. Browse all shop pages — visually identical to before (until blocks are edited)
  3. Theme editor preview works for all page types
  4. Cart flow: add to cart, view cart, update quantity — all work
  5. PDP: variant selection, gallery, lightbox — all work
  6. Collection: filtering, sorting — all work
  7. Contact: order tracking form — works
  8. Search: results appear, product links work
  9. 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.