# 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"<BerrypodWeb.PageRenderer.render_page {assigns} />"
```

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