# 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:

```elixir
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.

```elixir
# 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:
```json
{"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:

```elixir
# 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:**
```elixir
[hero, category_nav, featured_products, image_text]
```

**PDP:**
```elixir
[breadcrumb, product_hero, trust_badges, product_details, reviews_section, related_products]
```

**Collection:**
```elixir
[collection_header, filter_bar, product_grid]
```

**Cart:**
```elixir
[cart_items, order_summary]
```

**Contact:**
```elixir
[hero, contact_form, order_tracking_card, info_card, newsletter_card, social_links_card]
```

**About:**
```elixir
[hero, content_body]
```

**Delivery / Privacy / Terms:**
```elixir
[hero, content_body]
```

**Checkout success:**
```elixir
[checkout_result]
```

**Orders:**
```elixir
[order_card]
```

**Order detail:**
```elixir
[order_detail_card]
```

**Error:**
```elixir
[hero, featured_products]
```

**Search:**
```elixir
[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

```elixir
# 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:

```css
/* 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:

```elixir
# 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`)

- [x] Migration, schema, block types registry (26 types), defaults (14 pages), page cache, context
- [x] 34 tests covering context CRUD, block types, defaults completeness, data loaders, cache

---

### Stage 2: Page renderer ✅

**Status:** Complete (commit `32f54c7`)

- [x] `BerrypodWeb.PageRenderer` — `render_page/1` wraps in shop_layout, `render_block/1` dispatches all 26 block types to existing shop components
- [x] Each block is self-contained HEEx (CSS grid handles multi-column layouts, not split tags)
- [x] `page_main_class/1`, `format_order_status/1`, `block_assigns/1` (preserves `__changed__` for Phoenix assigns)
- [x] 18 renderer tests covering all 14 pages
- [x] 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

- [x] Update `Shop.Home` — `Pages.get_page("home")` + `load_block_data/2` + PageRenderer
- [x] Update `Shop.Content` — `Pages.get_page(live_action)` for about/delivery/privacy/terms
- [x] Update `Shop.Contact` — `Pages.get_page("contact")`, keep tracking_state event handlers
- [x] Update `ErrorHTML` — `Pages.get_page("error")` with safe_load fallback
- [x] Page-level CSS: contact page grid layout
- [x] SEO meta tags still set correctly in each LiveView
- [x] All pages visually identical to before

---

### Stage 4: Wire up shop pages (Collection, PDP, Cart, Search) ✅

**Status:** Complete

- [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

---

### Stage 5: Wire up order pages + theme preview ✅

**Status:** Complete

- [x] Update `Shop.CheckoutSuccess` — `Pages.get_page("checkout_success")`, keep PubSub subscription
- [x] Update `Shop.Orders` — `Pages.get_page("orders")`
- [x] Update `Shop.OrderDetail` — `Pages.get_page("order_detail")`
- [x] Update theme editor — unified `preview_page/1` with `Pages.get_page(slug)` + `load_block_data/2` + PageRenderer (10 clauses → 1 + page-context helpers)
- [x] Removed `PageTemplates` module + 10 `.heex` template files (zero references remain)
- [x] `layout_assigns/1` already lives in `ShopComponents.Layout` — no move needed
- [x] 1284 tests pass, `mix precommit` clean

---

### Stage 6: Admin editor — page list + block management ✅

**Status:** Complete (commit `660fda9`)

- [x] `Admin.Pages.Index` — page list with 5 groups (Marketing, Legal, Shop, Orders, System), icons, block counts
- [x] `Admin.Pages.Editor` — block cards with position numbers, icons, names
- [x] Routes: `/admin/pages` and `/admin/pages/:slug`, "Pages" nav link in admin sidebar
- [x] Move up/down with ARIA live region announcements, disabled at edges
- [x] Remove block (with confirmation), duplicate block (copies settings)
- [x] "+ Add block" picker with search/filter, enforces `allowed_on` per page
- [x] Save → persist to DB, invalidate cache, flash. Reset to defaults with confirmation
- [x] `@dirty` flag + DirtyGuard JS hook for unsaved changes warning
- [x] Admin CSS for page list, block cards, block picker modal
- [x] 25 integration tests covering list, reorder, add, remove, duplicate, reset, save, dirty flag
- [x] Regenerated admin icons (81 rules) with `@layer admin` wrapping fix in mix task
- [x] Added `:key` to renderer block loop for correct LiveView diffing
- [x] 1309 tests pass, `mix precommit` clean

---

### Stage 7: Admin editor — inline block editing ✅

**Status:** Complete

- [x] Inline settings form generated from `settings_schema` — text, textarea, select, number, json (read-only)
- [x] Block expand/collapse with `@expanded` MapSet, edit button (cog icon) on blocks with settings
- [x] `phx-change` updates working state instantly, no Cancel/Apply (page-level Save/Reset handles it)
- [x] Number type coercion (form params → integers), schema defaults merged for missing keys
- [x] Full ARIA: `aria-expanded`, `aria-controls`, live region announcements, unique field IDs
- [x] Debouncing: 300ms on text/textarea, blur on select/number
- [x] 11 new tests (36 total in pages_test), 1320 tests total, `mix precommit` clean

---

### 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.
