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
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.
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`):
`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.
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`.
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.
`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.
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.
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)
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.
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
- "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:
```elixir
# In the editor LiveView assigns
@history [] # list of previous block states (newest first)
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.
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 ✅
- [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
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.
| `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.