berrypod/docs/plans/custom-pages.md

350 lines
12 KiB
Markdown
Raw Normal View History

# Custom CMS pages
Status: Planned
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).