diff --git a/PROGRESS.md b/PROGRESS.md
index 485b0ae..9fc2bf1 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -9,7 +9,7 @@
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
- Mobile-first design with bottom navigation
-- 1398 tests passing, 100% PageSpeed score
+- 1455 tests passing, 100% PageSpeed score
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
- Variant selector with color swatches and size buttons
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
@@ -143,9 +143,9 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~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 |
+| ~~97~~ | ~~Stage 1: data model + context — schema fields, split changeset, CRUD functions, cache~~ | — | 1.5h | done |
+| ~~98~~ | ~~Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling~~ | 97 | 1h | done |
+| ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done |
| 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 |
@@ -487,7 +487,7 @@ Admin media library at `/admin/media` with image grid, type/search/orphan filter
See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan
### Page Editor
-**Status:** In progress — Stage 8 of 9 complete, 1370 tests
+**Status:** In progress — Stage 8 of 9 complete, 1455 tests
Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven).
@@ -508,7 +508,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
- `lib/berrypod/pages/` — Page schema, BlockTypes (26 types), Defaults (14 pages), PageCache (ETS)
- `lib/berrypod_web/page_renderer.ex` — generic renderer dispatching blocks to existing shop components
- `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management)
-- `test/berrypod/pages_test.exs` — 34 tests
+- `test/berrypod/pages_test.exs` — 62 tests
- `test/berrypod_web/page_renderer_test.exs` — 18 tests
- `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields
- `lib/berrypod/pages/block_editor.ex` — pure functions for block manipulation
diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index b2fbd4c..36ff07a 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -1174,6 +1174,45 @@
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
}
+.page-card-custom {
+ padding: 0;
+ display: flex;
+ align-items: stretch;
+}
+
+.page-card-link {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 0 0.75rem 1rem;
+ text-decoration: none;
+ color: var(--t-text-primary);
+ min-width: 0;
+
+ @media (hover: hover) {
+ &:hover {
+ background: var(--t-surface-sunken);
+ }
+ }
+}
+
+.page-card-delete {
+ display: flex;
+ align-items: center;
+ padding: 0 0.75rem;
+ color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
+ cursor: pointer;
+ border: none;
+ background: none;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--t-error);
+ }
+ }
+}
+
/* Page editor split layout */
.page-editor-container {
diff --git a/lib/berrypod_web/live/admin/pages/custom_form.ex b/lib/berrypod_web/live/admin/pages/custom_form.ex
new file mode 100644
index 0000000..e363fca
--- /dev/null
+++ b/lib/berrypod_web/live/admin/pages/custom_form.ex
@@ -0,0 +1,158 @@
+defmodule BerrypodWeb.Admin.Pages.CustomForm do
+ use BerrypodWeb, :live_view
+
+ alias Berrypod.Pages
+ alias Berrypod.Pages.Page
+
+ @impl true
+ def mount(params, _session, socket) do
+ {:ok, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :new, _params) do
+ changeset = Page.custom_changeset(%Page{}, %{})
+
+ socket
+ |> assign(:page_title, "New page")
+ |> assign(:page_struct, %Page{})
+ |> assign(:slug_touched, false)
+ |> assign(:form, to_form(changeset))
+ end
+
+ defp apply_action(socket, :edit, %{"slug" => slug}) do
+ case Pages.get_page_struct(slug) do
+ %Page{type: "custom"} = page ->
+ changeset = Page.custom_changeset(page, %{})
+
+ socket
+ |> assign(:page_title, "#{page.title} settings")
+ |> assign(:page_struct, page)
+ |> assign(:slug_touched, true)
+ |> assign(:form, to_form(changeset))
+
+ _ ->
+ socket
+ |> put_flash(:error, "Page not found")
+ |> push_navigate(to: ~p"/admin/pages")
+ end
+ end
+
+ @impl true
+ def handle_event("validate", %{"page" => params}, socket) do
+ params = maybe_slugify(params, socket)
+
+ form =
+ socket.assigns.page_struct
+ |> Page.custom_changeset(params)
+ |> Map.put(:action, :validate)
+ |> to_form()
+
+ slug_touched =
+ socket.assigns.slug_touched || (params["slug"] && params["slug"] != "")
+
+ {:noreply, assign(socket, form: form, slug_touched: slug_touched)}
+ end
+
+ @impl true
+ def handle_event("save", %{"page" => params}, socket) do
+ save_page(socket, socket.assigns.live_action, params)
+ end
+
+ defp save_page(socket, :new, params) do
+ case Pages.create_custom_page(params) do
+ {:ok, page} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Page created")
+ |> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_page(socket, :edit, params) do
+ case Pages.update_custom_page(socket.assigns.page_struct, params) do
+ {:ok, page} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Page settings saved")
+ |> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ # Auto-generate slug from title when creating and slug hasn't been manually edited
+ defp maybe_slugify(params, %{assigns: %{live_action: :new, slug_touched: false}}) do
+ case params["title"] do
+ title when is_binary(title) and title != "" ->
+ slug =
+ title
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9\s-]/, "")
+ |> String.trim()
+ |> String.replace(~r/\s+/, "-")
+ |> String.replace(~r/-+/, "-")
+ |> String.trim("-")
+
+ Map.put(params, "slug", slug)
+
+ _ ->
+ params
+ end
+ end
+
+ defp maybe_slugify(params, _socket), do: params
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.link
+ navigate={~p"/admin/pages"}
+ class="text-sm font-normal text-base-content/60 hover:underline"
+ >
+ ← Pages
+
+
+ <.header>
+ {if @live_action == :new, do: "New page", else: "Page settings"}
+
+
+ <.form
+ for={@form}
+ id="custom-page-form"
+ phx-change="validate"
+ phx-submit="save"
+ class="mt-6 space-y-6"
+ style="max-width: 32rem;"
+ >
+ <.input field={@form[:title]} label="Title" />
+ <.input field={@form[:slug]} label="URL slug" />
+ <.input
+ field={@form[:meta_description]}
+ type="textarea"
+ label="Meta description"
+ phx-no-feedback
+ />
+ <.input field={@form[:published]} type="checkbox" label="Published" />
+ <.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
+
+
+ <.input field={@form[:nav_label]} label="Nav label" />
+ <.input field={@form[:nav_position]} type="number" label="Nav position" />
+
+
+
+ <.button type="submit" phx-disable-with="Saving...">
+ {if @live_action == :new, do: "Create page", else: "Save settings"}
+
+ <.link navigate={~p"/admin/pages"} class="admin-btn admin-btn-ghost">
+ Cancel
+
+
+
+ """
+ end
+end
diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex
index 8b93d0a..8c65a8f 100644
--- a/lib/berrypod_web/live/admin/pages/editor.ex
+++ b/lib/berrypod_web/live/admin/pages/editor.ex
@@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
- alias Berrypod.Pages.{BlockEditor, BlockTypes}
+ alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@@ -38,7 +38,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
- |> assign(:image_picker_search, "")}
+ |> assign(:image_picker_search, "")
+ |> assign(:is_custom_page, !Page.system_slug?(slug))}
end
# ── Block manipulation events ────────────────────────────────────
@@ -310,7 +311,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
/>
{if @show_preview, do: "Edit", else: "Preview"}
+ <.link
+ :if={@is_custom_page}
+ navigate={~p"/admin/pages/#{@slug}/settings"}
+ class="admin-btn admin-btn-sm admin-btn-ghost"
+ >
+ <.icon name="hero-cog-6-tooth" class="size-4" /> Settings
+