berrypod/docs/plans/custom-pages.md
jamey 356e336eef
All checks were successful
deploy / deploy (push) Successful in 1m22s
plan custom CMS pages feature with catch-all routing
Six-stage plan for user-created content pages at top-level URLs.
System pages keep dedicated LiveViews, custom pages use a single
generic LiveView with portable blocks. Includes navigation
management, SEO, and auto-redirects on slug change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:40:37 +00:00

12 KiB

Custom CMS pages

Status: Planned See also: 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

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/0list_system_pages/0 (iterates Page.system_slugs()).
  • Add list_custom_pages/0 — queries DB for type: "custom", ordered by title.
  • Add list_all_pages/0list_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:

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):

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):

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.

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

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