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 + + + + """ end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index fc52685..5b1cede 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -155,6 +155,8 @@ defmodule BerrypodWeb.Router do live "/settings", Admin.Settings, :index live "/settings/email", Admin.EmailSettings, :index live "/pages", Admin.Pages.Index, :index + live "/pages/new", Admin.Pages.CustomForm, :new + live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit live "/pages/:slug", Admin.Pages.Editor, :edit live "/media", Admin.Media, :index live "/redirects", Admin.Redirects, :index diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 3389259..12144b6 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -701,6 +701,202 @@ defmodule BerrypodWeb.Admin.PagesTest do end end + describe "custom pages on index" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "shows new page button", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages") + assert has_element?(view, "a[href='/admin/pages/new']", "New page") + end + + test "shows custom pages section when pages exist", %{conn: conn} do + {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"}) + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + assert has_element?(view, ".page-group-title", "Custom pages") + assert has_element?(view, ".page-card-title", "FAQ") + end + + test "does not show custom pages section when empty", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages") + refute has_element?(view, ".page-group-title", "Custom pages") + end + + test "shows draft badge for unpublished pages", %{conn: conn} do + {:ok, _} = + Pages.create_custom_page(%{slug: "draft-page", title: "Draft", published: false}) + + {:ok, view, _html} = live(conn, ~p"/admin/pages") + assert has_element?(view, ".admin-badge-warning", "Draft") + end + + test "custom page card links to editor", %{conn: conn} do + {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"}) + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + assert has_element?(view, "a[href='/admin/pages/faq']") + end + + test "shows slug in card meta", %{conn: conn} do + {:ok, _} = Pages.create_custom_page(%{slug: "our-story", title: "Our story"}) + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + assert has_element?(view, ".page-card-meta", "/our-story") + end + + test "delete removes page from list", %{conn: conn} do + {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"}) + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + assert has_element?(view, ".page-card-title", "FAQ") + + view + |> element("button[phx-click='delete_custom_page'][phx-value-slug='faq']") + |> render_click() + + refute has_element?(view, ".page-card-title", "FAQ") + assert render(view) =~ "Page deleted" + end + end + + describe "custom page creation" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "renders creation form", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + assert has_element?(view, "h1", "New page") + assert has_element?(view, "#custom-page-form") + end + + test "creating with valid data redirects to editor", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + view + |> form("#custom-page-form", page: %{title: "FAQ", slug: "faq"}) + |> render_submit() + + assert_redirect(view, ~p"/admin/pages/faq") + end + + test "creating with empty title shows error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + view + |> form("#custom-page-form", page: %{title: "", slug: "faq"}) + |> render_submit() + + assert has_element?(view, "#custom-page-form") + end + + test "creating with reserved slug shows error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + html = + view + |> form("#custom-page-form", page: %{title: "Admin", slug: "admin"}) + |> render_submit() + + assert html =~ "is reserved" + end + + test "creating with duplicate slug shows error", %{conn: conn} do + {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"}) + + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + html = + view + |> form("#custom-page-form", page: %{title: "FAQ 2", slug: "faq"}) + |> render_submit() + + assert html =~ "has already been taken" + end + + test "auto-slugifies title during validation", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/new") + + html = + view + |> form("#custom-page-form", page: %{title: "Our Story"}) + |> render_change() + + assert html =~ ~s(value="our-story") + end + end + + describe "custom page settings" do + setup %{conn: conn, user: user} do + {:ok, _} = Pages.create_custom_page(%{slug: "help-page", title: "Help"}) + %{conn: log_in_user(conn, user)} + end + + test "renders settings form", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings") + + assert has_element?(view, "h1", "Page settings") + assert has_element?(view, "#custom-page-form") + end + + test "updating title saves correctly", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings") + + view + |> form("#custom-page-form", page: %{title: "Help centre"}) + |> render_submit() + + assert_redirect(view, ~p"/admin/pages/help-page") + + page = Pages.get_page("help-page") + assert page.title == "Help centre" + end + + test "updating slug redirects to new editor path", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings") + + view + |> form("#custom-page-form", page: %{slug: "support"}) + |> render_submit() + + assert_redirect(view, ~p"/admin/pages/support") + end + + test "settings for nonexistent page redirects", %{conn: conn} do + assert {:error, {:live_redirect, %{to: "/admin/pages"}}} = + live(conn, ~p"/admin/pages/nonexistent/settings") + end + end + + describe "editor for custom pages" do + setup %{conn: conn, user: user} do + {:ok, _} = Pages.create_custom_page(%{slug: "size-guide", title: "Size guide"}) + %{conn: log_in_user(conn, user)} + end + + test "shows settings button for custom pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide") + + assert has_element?(view, "a[href='/admin/pages/size-guide/settings']", "Settings") + end + + test "does not show reset to defaults for custom pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide") + + refute has_element?(view, "button", "Reset to defaults") + end + + test "shows reset to defaults for system pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + assert has_element?(view, "button", "Reset to defaults") + refute has_element?(view, "a[href='/admin/pages/home/settings']") + end + end + defp count_repeater_items(html) do Regex.scan(~r/class="repeater-item"/, html) |> length() end diff --git a/test/berrypod_web/live/shop/custom_page_test.exs b/test/berrypod_web/live/shop/custom_page_test.exs index 0bc6f2b..0545dfe 100644 --- a/test/berrypod_web/live/shop/custom_page_test.exs +++ b/test/berrypod_web/live/shop/custom_page_test.exs @@ -9,6 +9,8 @@ defmodule BerrypodWeb.Shop.CustomPageTest do setup do PageCache.invalidate_all() + # Clear redirect ETS cache to avoid stale entries from other tests + if :ets.whereis(:redirects_cache) != :undefined, do: :ets.delete_all_objects(:redirects_cache) user = user_fixture() {:ok, _} = Berrypod.Settings.set_site_live(true) %{user: user}