diff --git a/PROGRESS.md b/PROGRESS.md index c16b3ac..485b0ae 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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) | [page-builder.md](docs/plans/page-builder.md) | [media-library.md](docs/plans/media-library.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) | [media-library.md](docs/plans/media-library.md) | [custom-pages.md](docs/plans/custom-pages.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -142,6 +142,13 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~94~~ | ~~Admin media library UI — grid, filters, upload, detail panel, orphan management~~ | 93 | 2.5h | done | | ~~95~~ | ~~Image picker for page builder — `:image` field type, image_id resolution in renderer~~ | 94 | 2h | done | | 96 | Polish — theme editor alt text, full modal picker, orphan cleanup on ref removal | 95 | 1h | planned | +| | **Custom CMS pages** ([plan](docs/plans/custom-pages.md)) | | | | +| 97 | Stage 1: data model + context — schema fields, split changeset, CRUD functions, cache | — | 1.5h | planned | +| 98 | Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling | 97 | 1h | planned | +| 99 | Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index | 98 | 2.5h | planned | +| 100 | Stage 4: navigation management — data-driven nav, settings storage, admin editor | 99 | 3h | planned | +| 101 | Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published | 100 | 1h | planned | +| 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred | | | **Platform site** | | | | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | | 74 | Separation of concerns: platform site vs AGPL open source core | 73 | TBD | planned | diff --git a/docs/plans/custom-pages.md b/docs/plans/custom-pages.md new file mode 100644 index 0000000..bc6ab52 --- /dev/null +++ b/docs/plans/custom-pages.md @@ -0,0 +1,349 @@ +# 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"" +``` + +### 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).