# Custom CMS pages Status: Complete See also: [page-builder.md](page-builder.md) (existing page editor plan) Six stages, ordered by priority. Each stage is independently shippable. --- ## Context The page builder currently works with a fixed set of 14 system slugs (home, about, cart, etc.). Each maps to a dedicated LiveView with its own event handlers. The renderer, block system, and editor hook are already fully generic — they work with any slug. The constraints are localised to a handful of hardcoded lists. This plan adds user-created content pages at arbitrary top-level URLs using a catch-all-last routing strategy. System pages keep their dedicated LiveViews. Custom pages use a single generic LiveView with portable blocks only. Navigation becomes data-driven. **Key architectural decision:** system pages (cart, PDP, collection, etc.) stay as dedicated LiveViews because their blocks need custom event handlers. Custom pages only get portable blocks (hero, image_text, content_body, featured_products, etc.) via the existing `allowed_on: :all` filtering. This is the same approach WordPress, Shopify, and every CMS uses — reserved system routes, catch-all for everything else. --- ## Stage 1: Data model + context (unblocks everything) **Goal:** Custom pages can be created, stored, and retrieved. No UI yet. ### 1a. Migration New file: `priv/repo/migrations/TIMESTAMP_add_custom_page_fields.exs` ```elixir alter table(:pages) do add :type, :string, default: "system" # "system" | "custom" add :published, :boolean, default: true add :meta_description, :string add :show_in_nav, :boolean, default: false add :nav_label, :string # display name in nav (defaults to title) add :nav_position, :integer # ordering within nav end ``` No index changes — the existing unique index on `slug` covers custom pages. ### 1b. Page schema File: `lib/berrypod/pages/page.ex` - Rename `@valid_slugs` → `@system_slugs` (line 8). Public accessor `system_slugs/0`. - Add `@reserved_paths` — every first-segment used by system routes, preventing slug collisions: ``` ~w(about delivery privacy terms contact collections products cart search checkout orders coming-soon admin health image_cache images favicon sitemap.xml robots.txt setup dev) ``` - Add new fields to schema: `:type`, `:published`, `:meta_description`, `:show_in_nav`, `:nav_label`, `:nav_position`. - Split changeset: - `system_changeset/2` — existing validation with `validate_inclusion(:slug, @system_slugs)`. - `custom_changeset/2` — URL-safe slug format (`~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/`), exclusion from `@system_slugs ++ @reserved_paths`, sets `type: "custom"`. Required: `:slug`, `:title`. - Add `system_slug?/1` and `reserved_path?/1` helpers. ### 1c. Defaults fallback File: `lib/berrypod/pages/defaults.ex` Add catch-all clauses: - `defp title(slug), do: slug |> String.replace("-", " ") |> String.capitalize()` - `defp blocks(_slug), do: []` Only fires for unknown slugs. System slugs still get their specific defaults. ### 1d. Pages context File: `lib/berrypod/pages.ex` - Rename `list_pages/0` → `list_system_pages/0` (iterates `Page.system_slugs()`). - Add `list_custom_pages/0` — queries DB for `type: "custom"`, ordered by `title`. - Add `list_all_pages/0` — `list_system_pages() ++ list_custom_pages()`. - Add `create_custom_page/1` — takes `%{slug:, title:}`, uses `custom_changeset/2`, inserts. - Add `delete_custom_page/1` — deletes by slug, only if `type == "custom"`. Invalidates cache. - Add `update_custom_page/2` — updates title, slug, meta, published, nav fields. Slug change auto-creates redirect via `Redirects.create_auto/1`. - Modify `save_page/2` — use `system_changeset` for system slugs, `custom_changeset` for custom. - Modify `reset_page/1` — for custom pages, delete entirely (no defaults to fall back to). - Update `page_to_map/1` (line 126) — include new fields. - `get_page_from_db/1` (line 37) — return `nil` for non-system slugs not in DB (don't call `Defaults.for_slug/1`). ### 1e. Page cache File: `lib/berrypod/pages/page_cache.ex` - `warm/0` (line 50) — after system slugs, also query DB for all custom pages and cache them. ### 1f. Tests File: `test/berrypod/pages_test.exs` - `create_custom_page/1` — valid slug, duplicate, reserved slug, system slug collision. - `delete_custom_page/1` — custom page, system page (refuse). - `update_custom_page/2` — title change, slug change (verify redirect created). - `list_custom_pages/0` — empty, with pages, ordering. - `get_page/1` for custom slug — nil when not created, page when created. - Update existing `list_pages` test (now `list_system_pages`). **Files:** `page.ex`, `pages.ex`, `defaults.ex`, `page_cache.ex`, migration, `pages_test.exs` **Est:** 1.5h --- ## Stage 2: Routing + custom page LiveView **Goal:** Custom pages accessible at `/:slug` on the shop. ### 2a. New LiveView New file: `lib/berrypod_web/live/shop/custom_page.ex` Follows `Shop.Content` pattern — loads page in `handle_params` so PageEditorHook deferred init works: ```elixir def mount(_params, _session, socket), do: {:ok, socket} def handle_params(%{"slug" => slug}, _uri, socket) do page = Pages.get_page(slug) if is_nil(page) or page[:type] != "custom" or page[:published] != true do raise BerrypodWeb.NotFoundError # or however 404s are handled end {:noreply, socket |> assign(:page, page) |> assign(:page_title, page.title) |> assign(:page_description, page[:meta_description]) |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/#{page.slug}")} end def render(assigns), do: ~H"" ``` ### 2b. Router File: `lib/berrypod_web/router.ex` Add as the **last** route in `public_shop` live_session (after line 88): ```elixir live "/:slug", Shop.CustomPage, :show ``` Only matches single-segment paths. Multi-segment paths (`/collections/all`, `/orders/123`) matched by earlier explicit routes. Route order guarantees system routes always win. ### 2c. Tests New file: `test/berrypod_web/live/shop/custom_page_test.exs` - Published custom page renders at `/:slug`. - Unpublished page → 404. - Nonexistent slug → 404. - System routes unaffected (`/about`, `/cart` still hit their LiveViews). - Page editor works on custom pages (`?edit=true`). - SEO assigns set correctly. **Files:** `router.ex`, new `custom_page.ex`, new test file **Est:** 1h --- ## Stage 3: Admin CRUD for custom pages **Goal:** Admins can create, edit, and delete custom pages. ### 3a. Admin pages index File: `lib/berrypod_web/live/admin/pages/index.ex` - "Custom pages" section below system page groups. - Load via `Pages.list_custom_pages()`. - "Create page" button → `/admin/pages/new`. - `page_icon/1` catch-all: `defp page_icon(_), do: "hero-document"`. - "Draft" badge when `published == false`. ### 3b. Page creation flow File: `lib/berrypod_web/live/admin/pages/editor.ex` - `%{"slug" => "new"}` in mount → creation mode. - Form: title (required), auto-generated slug (editable), meta description. - Submit: `Pages.create_custom_page/1` → redirect to `/admin/pages/#{slug}`. - Slug generation: `title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-")`. ### 3c. Page settings for custom pages File: `lib/berrypod_web/live/admin/pages/editor.ex` - Detect custom via `page.type == "custom"`. - Editable settings section: title, slug, meta description, published toggle, show in nav, nav label. - Slug changes call `Pages.update_custom_page/2` (auto-redirect). ### 3d. Delete page - Custom pages only: "Delete page" button with confirmation. - `Pages.delete_custom_page/1` → redirect to `/admin/pages`. ### 3e. Admin route File: `lib/berrypod_web/router.ex` - `live "/pages/new", Admin.Pages.Editor, :new` — BEFORE existing `/pages/:slug`. ### 3f. Tests - Create page: form validation, slug generation, redirect. - Edit title/slug/description. - Delete custom page. - Custom pages in page list. - Cannot delete system pages. - Cannot create with reserved slugs. **Files:** `admin/pages/index.ex`, `admin/pages/editor.ex`, `router.ex`, tests, admin CSS **Est:** 2.5h --- ## Stage 4: Navigation management **Goal:** Header, footer, and mobile nav are data-driven and configurable. ### 4a. Data model Store as JSON settings (existing `Settings` system): ```elixir Settings.put_setting("header_nav", Jason.encode!(items), "json") Settings.put_setting("footer_nav", Jason.encode!(items), "json") ``` Each item: `%{"label" => "Home", "href" => "/", "slug" => "home"}` `slug` is optional — used for active-page highlighting. Default items match current hardcoded nav. ### 4b. Load in ThemeHook File: `lib/berrypod_web/theme_hook.ex` - Load `header_nav`/`footer_nav` settings in `mount_theme`. - Assign `@header_nav_items`, `@footer_nav_items`. - Fall back to default items if not configured. ### 4c. Data-driven rendering File: `lib/berrypod_web/components/shop_components/layout.ex` - **Header** (lines 714-737): Replace hardcoded items with `:for` loop over `@header_nav_items`. - **Footer "Help" column** (lines 573-653): Replace with loop over `@footer_nav_items`. - **Mobile bottom nav** (lines 184-227): Keep fixed for now (icon slots are a bigger UX decision). - Add `header_nav_items` and `footer_nav_items` to `@layout_keys` (line 51). ### 4d. Admin nav editor New file: `lib/berrypod_web/live/admin/navigation.ex` - Route: `/admin/navigation` - Header + footer item lists with label/href inputs. - Move up/down buttons (same pattern as block editor). - "Add item" + "Add page" (dropdown of all pages). - Save → settings. "Reset to defaults" button. ### 4e. Admin sidebar link File: `lib/berrypod_web/components/layouts/admin.html.heex` — add "Navigation" link. ### 4f. Tests - Nav renders from settings. - Defaults match current hardcoded items. - Admin CRUD for nav items. **Files:** `layout.ex`, `theme_hook.ex`, admin layout, new `navigation.ex`, router, CSS, tests **Est:** 3h --- ## Stage 5: SEO + redirects **Goal:** Custom pages are search-engine friendly. ### 5a. Sitemap File: `lib/berrypod_web/controllers/seo_controller.ex` ```elixir custom_pages = Pages.list_custom_pages() |> Enum.filter(& &1.published) |> Enum.map(fn p -> {"/#{p.slug}", "weekly", "0.6"} end) all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages ``` ### 5b. Auto-redirect on slug change Already wired in stage 1d. Verify and test. Existing redirect plug handles all paths. ### 5c. Draft enforcement - Admin preview: allow viewing unpublished pages if `is_admin`, show "Draft" banner. - Sitemap/nav exclude unpublished. ### 5d. Tests - Sitemap includes published, excludes unpublished. - Slug change creates 301 redirect. - Draft pages 404 for visitors, render for admins. **Files:** `seo_controller.ex`, `pages.ex`, `custom_page.ex`, tests **Est:** 1h --- ## Stage 6: Polish (defer) - **Page templates** — blank, content page, landing page starter layouts. - **New block types** — spacer, rich text, image gallery, video embed (all `allowed_on: :all`). - **Bulk operations** — publish/unpublish multiple, duplicate page. - **Page ordering** — position field, move up/down in admin list. **Est:** 3-4h total, incremental --- ## Key files | File | Stages | Change | |------|--------|--------| | `lib/berrypod/pages/page.ex` | 1 | Schema, split changeset | | `lib/berrypod/pages.ex` | 1, 5 | New CRUD functions | | `lib/berrypod/pages/defaults.ex` | 1 | Catch-all clauses | | `lib/berrypod/pages/page_cache.ex` | 1 | Warm custom pages | | `lib/berrypod_web/router.ex` | 2, 3 | Catch-all + admin routes | | `lib/berrypod_web/live/shop/custom_page.ex` | 2, 5 | New LiveView | | `lib/berrypod_web/live/admin/pages/index.ex` | 3 | Custom pages section | | `lib/berrypod_web/live/admin/pages/editor.ex` | 3 | Create/edit/delete | | `lib/berrypod_web/components/shop_components/layout.ex` | 4 | Data-driven nav | | `lib/berrypod_web/theme_hook.ex` | 4 | Load nav settings | | `lib/berrypod_web/live/admin/navigation.ex` | 4 | New admin page | | `lib/berrypod_web/controllers/seo_controller.ex` | 5 | Sitemap | | `assets/css/admin/components.css` | 3, 4 | Admin styles | ## Verification After each stage: `mix precommit`, browser check at `localhost:4000`. Full end-to-end: create page from admin → renders at `/:slug` → edit with live editor → change slug (redirect works) → add to nav → check sitemap → unpublish (404 for visitors).