diff --git a/PROGRESS.md b/PROGRESS.md index 30cdde7..87877b3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -9,7 +9,7 @@ - Image optimization pipeline (AVIF/WebP/JPEG responsive variants) - Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms) - Mobile-first design with bottom navigation -- 972 tests passing, 100% PageSpeed score +- 1284 tests passing, 100% PageSpeed score - SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit) - Variant selector with color swatches and size buttons - Session-based cart with real variant data (add/remove/quantity, cross-tab sync) @@ -30,7 +30,7 @@ Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md) | [page-builder.md](docs/plans/page-builder.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -117,11 +117,11 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | 70 | Margin guard on sales (prevent discounts that breach minimum profit threshold) | 69 | 1h | planned | | 71 | Announcement bar (dismissable shop banner for active sales, admin-configurable) | 69 | 1.5h | planned | | | **URL redirects** ([plan](docs/plans/url-redirects.md)) | | | | -| 78 | `redirects` + `broken_urls` schemas, `Redirects` context, ETS-cached Plug in pipeline | — | 2h | planned | -| 79 | Auto-redirect on slug change — hook into `upsert_product/2` to detect old/new slug diff | 78 | 45m | planned | -| 80 | Analytics-powered 404 monitoring — query analytics on 404, FTS5 auto-resolve, broken URLs queue | 78 | 2h | planned | -| 81 | Admin redirects UI — active redirects, broken URLs (sorted by prior traffic), manual create | 78 | 2h | planned | -| 82 | Dead link monitoring — validate stored links (internal via Phoenix.Router, external via async Oban HEAD), event-driven on product changes, admin dead links tab | 78 | 2.5h | planned | +| ~~78~~ | ~~`redirects` + `broken_urls` schemas, `Redirects` context, ETS-cached Plug in pipeline~~ | — | 2h | done | +| ~~79~~ | ~~Auto-redirect on slug change — hook into `upsert_product/2` to detect old/new slug diff~~ | 78 | 45m | done | +| ~~80~~ | ~~Analytics-powered 404 monitoring — query analytics on 404, FTS5 auto-resolve, broken URLs queue~~ | 78 | 2h | done | +| ~~81~~ | ~~Admin redirects UI — active redirects, broken URLs (sorted by prior traffic), manual create~~ | 78 | 2h | done | +| 82 | Dead link monitoring — validate stored links (internal via Phoenix.Router, external via async Oban HEAD), event-driven on product changes, admin dead links tab | page editor | 2.5h | deferred | | | **Activity log & order timeline** ([plan](docs/plans/activity-log.md)) | | | | | 89 | `activity_log` schema + migration + `ActivityLog` context (`log_event/3`, `list_for_order/1`, `list_recent/1`, `count_needing_attention/0`, `resolve/1`) | — | 1.5h | planned | | 90 | Instrument existing event points — stripe webhook, OrderNotifier, OrderSubmissionWorker, fulfilment status, ProductSyncWorker | 89 | 1.5h | planned | @@ -183,7 +183,7 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp ### Tier 4 — Growth & content -21. **Page editor** — Database-driven pages with drag-and-drop sections. Extend the theme system to custom pages beyond the defaults. Replaces the static content pages from Tier 1 with editable versions. **Removes PreviewData usage from `content.ex`** (about, delivery, privacy, terms content blocks are currently hardcoded in PreviewData). +21. **Page editor** — Database-driven page builder where every page is a list of blocks. Generic renderer, portable + page-specific blocks, block data loaders, ETS cache, mobile-first admin editor. See [plan](docs/plans/page-builder.md). 22. **Legal page generator** — Replace the hardcoded `PreviewData` placeholder content on `/privacy`, `/delivery`, and `/terms` with generated content that's factually accurate for each shop. Berrypod already knows which providers are connected (each with different lead times), which countries it ships to (from the shipping rates table), whether VAT is enabled, whether abandoned cart recovery is on, the shop country (drives jurisdiction language). Privacy policy cites correct statutes (UK GDPR, PECR) and includes conditional sections only for features that are actually enabled. Delivery policy quotes real shipping destinations from DB and correctly applies the Consumer Contracts Regulations Regulation 28(1)(b) exemption for POD (made-to-order goods are exempt from the 14-day right to cancel — most generic templates get this wrong). Terms cites governing law from shop country. Phase 2 (after page editor): "Regenerate from settings" button and auto-regeneration when settings change. See [plan](docs/plans/legal-page-generator.md). 23. **Newsletter & email marketing** — Email list collection (signup forms). Campaign sending for product launches, sales. Can be simple initially (collect emails, send via Swoosh) or integrate with a service. Ties into sales & promotions for sale announcement emails. 24. **Abandoned cart recovery** — Privacy-respecting, GDPR-compliant recovery for customers who started Stripe Checkout but didn't complete payment. Triggered by `checkout.session.expired` webhook (Stripe fires this after 24h). Only possible for customers who entered their email on the Stripe Checkout page — anonymous cart sessions with no email are never contacted. Single plain-text email, no tracking pixels, one-click unsubscribe (suppression list honoured for all future emails). Abandoned cart records deleted after 30 days. Stripe Checkout footer text notifies customers at collection time. Lawful basis: UK PECR soft opt-in (email obtained during negotiation of a sale, single follow-up for similar products). EU: legitimate interests with documented LIA. See [plan](docs/plans/abandoned-cart.md). @@ -458,11 +458,29 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan ### Page Editor -**Status:** Future (Tier 4) +**Status:** In progress — Stage 2 of 9 complete, 1284 tests -Database-driven pages with drag-and-drop sections. Initially, default content pages (terms, privacy, delivery) will be static templates (Tier 1), later made editable via the page editor. +Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). -See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design +**Stages:** +1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`) +2. ~~Page renderer — generic renderer tested in isolation~~ ✅ (`32f54c7`) +3. **Next →** Wire simple pages — Home, Content (x4), Contact, Error +4. Wire shop pages — Collection, PDP, Cart, Search +5. Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor +6. Admin editor — page list + block management (reorder, add, remove, duplicate, save) +7. Admin editor — inline block settings editing +8. Live preview — split layout with real-time preview +9. Undo/redo + polish — history stacks, keyboard shortcuts, animations + +**Key files created:** +- `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) +- `lib/berrypod/pages/` — Page schema, BlockTypes (26 types), Defaults (14 pages), PageCache (ETS) +- `lib/berrypod_web/page_renderer.ex` — generic renderer dispatching blocks to existing shop components +- `test/berrypod/pages_test.exs` — 34 tests +- `test/berrypod_web/page_renderer_test.exs` — 18 tests + +See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan --- diff --git a/docs/plans/page-builder.md b/docs/plans/page-builder.md index f9ab750..5af4199 100644 --- a/docs/plans/page-builder.md +++ b/docs/plans/page-builder.md @@ -1,399 +1,777 @@ -# Page Builder Feature Plan +# Page builder plan -## Overview +Status: In progress (Stage 2 complete) -Transform the static page templates into a database-driven, customizable page builder. Users will be able to add, remove, reorder, and configure page sections through a visual editor similar to the existing theme editor. +## Context -## Goals +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. -1. Allow customization of page layouts without code changes -2. Provide real-time preview while editing -3. Support responsive editing (works on mobile and desktop) -4. Maintain theme consistency with existing design system +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 -### Current State +### Generic renderer — one function for all pages -``` -PageTemplates/ -├── home.html.heex # Static: hero → category_nav → featured_products → image_text -├── collection.html.heex -├── pdp.html.heex -├── cart.html.heex -├── about.html.heex -├── contact.html.heex -└── error.html.heex -``` - -### Future State - -``` -Database (page_layouts table) -├── page: "home" -│ └── sections: [ -│ {type: "hero", order: 1, settings: {...}}, -│ {type: "category_nav", order: 2, settings: {...}}, -│ {type: "featured_products", order: 3, settings: {...}}, -│ {type: "image_text", order: 4, settings: {...}} -│ ] -├── page: "about" -│ └── sections: [...] -└── ... - -PageRenderer module -├── render_page/2 # Renders page from layout + data -└── render_section/1 # Dispatches to component by type -``` - -## Data Model - -### PageLayout Schema +No page-specific render functions. One `render_page/1` renders ALL pages: ```elixir -defmodule Berrypod.Content.PageLayout do - use Ecto.Schema - - schema "page_layouts" do - field :page_type, :string # "home", "about", "collection", etc. - field :name, :string # Display name for the layout - field :is_default, :boolean, default: false - - has_many :sections, Berrypod.Content.PageSection - - timestamps() - end -end -``` - -### PageSection Schema - -```elixir -defmodule Berrypod.Content.PageSection do - use Ecto.Schema - - schema "page_sections" do - field :section_type, :string # "hero", "featured_products", etc. - field :order, :integer - field :settings, :map # JSON settings for the section - field :enabled, :boolean, default: true - - belongs_to :page_layout, Berrypod.Content.PageLayout - - timestamps() - end -end -``` - -### Section Types Registry - -```elixir -defmodule Berrypod.Content.SectionTypes do - @sections %{ - "hero" => %{ - name: "Hero Banner", - component: &BerrypodWeb.ShopComponents.hero_section/1, - settings_schema: %{ - title: %{type: :string, default: "Welcome"}, - description: %{type: :string, default: ""}, - cta_text: %{type: :string, default: "Shop now"}, - cta_page: %{type: :string, default: "collection"}, - background: %{type: :select, options: [:default, :sunken], default: :default} - }, - allowed_on: [:home, :about, :contact, :error] - }, - "featured_products" => %{ - name: "Featured Products", - component: &BerrypodWeb.ShopComponents.featured_products_section/1, - settings_schema: %{ - title: %{type: :string, default: "Featured products"}, - product_count: %{type: :integer, default: 8} - }, - allowed_on: [:home] - }, - "category_nav" => %{ - name: "Category Navigation", - component: &BerrypodWeb.ShopComponents.category_nav/1, - settings_schema: %{}, - allowed_on: [:home] - }, - "image_text" => %{ - name: "Image + Text Block", - component: &BerrypodWeb.ShopComponents.image_text_section/1, - settings_schema: %{ - title: %{type: :string}, - description: %{type: :text}, - image_url: %{type: :image}, - link_text: %{type: :string}, - link_page: %{type: :string} - }, - allowed_on: [:home, :about] - }, - "content_body" => %{ - name: "Rich Text Content", - component: &BerrypodWeb.ShopComponents.content_body/1, - settings_schema: %{ - image_url: %{type: :image}, - content: %{type: :rich_text} - }, - allowed_on: [:about, :contact] - }, - "reviews_section" => %{ - name: "Customer Reviews", - component: &BerrypodWeb.ShopComponents.reviews_section/1, - settings_schema: %{}, - allowed_on: [:pdp] - }, - "related_products" => %{ - name: "Related Products", - component: &BerrypodWeb.ShopComponents.related_products_section/1, - settings_schema: %{}, - allowed_on: [:pdp] - } - } -end -``` - -## Page Renderer - -```elixir -defmodule BerrypodWeb.PageRenderer do - use Phoenix.Component - import BerrypodWeb.ShopComponents - - alias Berrypod.Content.SectionTypes - - @doc """ - Renders a page from its layout and data context. - """ - def render_page(assigns) do - ~H""" -
- <.skip_link /> - <%= if @theme_settings.announcement_bar do %> - <.announcement_bar theme_settings={@theme_settings} /> +def render_page(assigns) do + ~H""" + <.shop_layout {layout_assigns(assigns)} active_page={@page.slug}> +
+ <%= for block <- @page.blocks do %> + <.render_block block={block} {assigns} /> <% end %> - - <.shop_header {...} /> - -
- <%= for section <- @page_layout.sections do %> - <.render_section - section={section} - data={@data} - theme_settings={@theme_settings} - mode={@mode} - /> - <% end %> -
- - <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer {...} /> - <.search_modal {...} /> -
- """ - end - - defp render_section(assigns) do - section_config = SectionTypes.get(assigns.section.section_type) - component = section_config.component - settings = Map.merge(section_config.default_settings, assigns.section.settings) - - assigns = assign(assigns, :settings, settings) - - ~H""" - <%= if @section.enabled do %> - <%= component.(@settings |> Map.merge(%{mode: @mode, data: @data})) %> - <% end %> - """ - end + + + """ end ``` -## Editor UI +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. -### Layout Structure +### Composite blocks for coupled content -``` -┌─────────────────────────────────────────────────────────────┐ -│ Desktop (≥1024px) │ -├───────────────────┬─────────────────────────────────────────┤ -│ │ │ -│ Section List │ Live Preview │ -│ ┌───────────┐ │ │ -│ │ Hero │ │ ┌─────────────────────────────────┐ │ -│ │ ≡ ✎ ✕ │ │ │ │ │ -│ └───────────┘ │ │ [Hero Section Preview] │ │ -│ ┌───────────┐ │ │ │ │ -│ │ Products │ │ ├─────────────────────────────────┤ │ -│ │ ≡ ✎ ✕ │ │ │ │ │ -│ └───────────┘ │ │ [Products Section Preview] │ │ -│ ┌───────────┐ │ │ │ │ -│ │ Image+Text│ │ └─────────────────────────────────┘ │ -│ │ ≡ ✎ ✕ │ │ │ -│ └───────────┘ │ │ -│ │ │ -│ [+ Add Section] │ │ -│ │ │ -└───────────────────┴─────────────────────────────────────────┘ +Content that's inherently paired shares a single block — the internal layout is handled by the block component, not the renderer. -┌─────────────────────────────────────────────────────────────┐ -│ Mobile (<1024px) │ -├─────────────────────────────────────────────────────────────┤ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Page: Home [Preview] ▼ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ Sections: │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ ≡ Hero ✎ ✕ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ ≡ Featured Products ✎ ✕ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ ≡ Image + Text ✎ ✕ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ [+ Add Section] │ -│ │ -│ ───────────────────────────────────────────────────────── │ -│ │ -│ [Preview Panel - expandable/collapsible] │ -│ │ -└─────────────────────────────────────────────────────────────┘ +| 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{}, ...]} ``` -### Key Interactions +**Three layers of data availability:** -1. **Drag & Drop Reordering**: Sections can be reordered via drag handles (≡) -2. **Edit Settings**: Clicking ✎ opens settings panel for that section -3. **Remove Section**: Clicking ✕ removes section (with confirmation) -4. **Add Section**: Opens modal/drawer with available section types -5. **Live Preview**: Updates in real-time as changes are made -6. **Undo/Redo**: Track changes for undo capability (optional) +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. -### Settings Panel +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. -When editing a section, show a form based on its `settings_schema`: +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 ``` -┌─────────────────────────────────────────┐ -│ Edit: Hero Banner ✕ │ -├─────────────────────────────────────────┤ -│ │ -│ Title │ -│ ┌─────────────────────────────────────┐ │ -│ │ Original designs, printed on demand │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ Description │ -│ ┌─────────────────────────────────────┐ │ -│ │ From art prints to apparel... │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ Button Text │ -│ ┌─────────────────────────────────────┐ │ -│ │ Shop the collection │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ Button Link │ -│ ┌─────────────────────────────────────┐ │ -│ │ collection ▼ │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ Background Style │ -│ ○ Default ● Sunken │ -│ │ -├─────────────────────────────────────────┤ -│ [Cancel] [Save Changes] │ -└─────────────────────────────────────────┘ +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 ``` -## Implementation Phases +Each block in the JSON array: +```json +{"id": "blk_abc123", "type": "hero", "settings": {"title": "...", "description": "..."}} +``` -### Phase 1: Data Model & Migration -- Create `page_layouts` and `page_sections` tables -- Create Ecto schemas -- Create seed data from current static templates -- Add context functions (CRUD) +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. -### Phase 2: Page Renderer -- Create `PageRenderer` module -- Create `SectionTypes` registry -- Update shop LiveViews to use renderer -- Update preview to use renderer -- Ensure backward compatibility (fallback to static if no layout) +### Block type registry -### Phase 3: Editor UI - Read Only -- Create `PageEditorLive` LiveView -- Display current sections in list -- Show live preview -- Page selector dropdown +`Berrypod.Pages.BlockTypes` — defines all block types. Each has: -### Phase 4: Editor UI - Basic Editing -- Add/remove sections -- Drag & drop reordering (using sortable.js or similar) -- Save changes to database +- `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}` -### Phase 5: Section Settings -- Settings panel component -- Dynamic form generation from schema -- Real-time preview updates +Key block types with their settings: -### Phase 6: Polish & UX -- Undo/redo support -- Keyboard shortcuts -- Mobile-optimized editing -- Loading states -- Error handling +```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"} + ] +} -## Technical Considerations +"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} + ] +} -### LiveView Communication +"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: ""} + ] +} -The editor needs to: -1. Load page layout on mount -2. Send updates on reorder/add/remove -3. Update preview in real-time +"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: []} -Using `phx-hook` for drag-and-drop and `push_event` for preview updates. +# 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 +} -### Caching +# 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: []} -Page layouts should be cached since they change infrequently: -- ETS cache similar to `CSSCache` -- Invalidate on save +# 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: []} -### Migration Path +# 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: []} -1. Deploy data model -2. Seed current layouts as "default" layouts -3. Deploy renderer (uses defaults if no custom layout) -4. Deploy editor -5. Users can customize +# 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: ""} + ] +} -### Permissions +# Checkout success (allowed_on: ["checkout_success"]) +"checkout_result" => %{name: "Checkout result", icon: "hero-check-circle", settings_schema: []} -- Only authenticated admins can edit layouts -- Consider draft/published states for layouts -- Preview unpublished changes before going live +# Orders (allowed_on: ["orders"]) +"order_card" => %{name: "Order cards", icon: "hero-clipboard-document-list", settings_schema: []} -## Open Questions +# Order detail (allowed_on: ["order_detail"]) +"order_detail_card" => %{name: "Order detail", icon: "hero-clipboard-document", settings_schema: []} -1. Should users be able to create multiple layouts per page type (A/B testing)? -2. Should sections support nesting (e.g., columns within a section)? -3. How to handle page-specific data (e.g., PDP needs product, collection needs filter state)? -4. Should there be a "reset to default" option? +# Search (allowed_on: ["search"]) +"search_results" => %{name: "Search results", icon: "hero-magnifying-glass", settings_schema: []} +``` -## Dependencies +### Default page definitions -- `sortable.js` or similar for drag-and-drop -- Possibly `@dnd-kit/sortable` if using JS framework -- JSON schema validation for settings +`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. -## Related Files +**Home:** +```elixir +[hero, category_nav, featured_products, image_text] +``` -- `lib/berrypod_web/components/shop_components.ex` - Existing section components -- `lib/berrypod_web/components/page_templates/` - Current static templates (will become defaults) -- `lib/berrypod_web/live/theme_live/index.ex` - Theme editor (reference implementation) +**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"" + +# 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"" +``` + +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 `
` 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 `` 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) + +**Goal:** first real pages switch to PageRenderer. These are the simplest — no URL-driven state, no streams. Visually identical to before. + +- [ ] 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 +- [ ] 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 + +--- + +### Stage 4: Wire up shop pages (Collection, PDP, Cart, Search) + +**Goal:** the complex shop pages switch to PageRenderer. These have URL-driven state, streams, JS hooks, and event handlers. + +- [ ] Update `Shop.Collection` — `Pages.get_page("collection")`, keep filter/sort handle_params +- [ ] Update `Shop.ProductShow` — `Pages.get_page("pdp")`, keep variant selection in handle_params, product_hero receives computed data +- [ ] Update `Shop.Cart` — `Pages.get_page("cart")`, cart events still handled by CartHook +- [ ] Update `Shop.Search` — `Pages.get_page("search")`, keep search handle_params +- [ ] Page-level CSS: PDP layout rules (product_hero handles two-column internally) +- [ ] Verify JS hooks survive: gallery carousel, lightbox, collection filters +- [ ] Verify variant selection, cart add/remove, search all work + +**Commit:** `wire collection, PDP, cart, and search pages to page renderer` + +**Verify:** `mix test` passes, full manual walkthrough of product browsing → add to cart → search flow + +--- + +### 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.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 — 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.