diff --git a/PROGRESS.md b/PROGRESS.md index 31d9bab..992db8f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -148,7 +148,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~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 | done | | ~~101~~ | ~~Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published~~ | 100 | 1h | done | -| 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred | +| ~~102~~ | ~~Stage 6: polish — page templates, new block types, bulk ops~~ | 101 | 3-4h | done | | | **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 | @@ -487,9 +487,9 @@ 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:** Complete — all 9 stages done, 1485 tests +**Status:** Complete — all 9 stages + custom pages done, 1485 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). +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). Custom CMS pages with 4 utility block types (spacer, divider, button/CTA, video embed), page templates (blank/content/landing) for new page creation, duplicate page action, and mobile sidebar fix for the block picker. **Stages:** 1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`) @@ -502,6 +502,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) 8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`) 9. ~~Undo/redo + polish — history stacks, keyboard shortcuts, settings animations~~ ✅ +10. ~~Custom pages stage 6 — 4 utility block types (spacer, divider, button/CTA, video embed), page templates (blank/content/landing), duplicate page, mobile block picker fix~~ ✅ **Key files created:** - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 277ec0b..5feb7f9 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1869,6 +1869,7 @@ } .image-picker-item { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -1927,6 +1928,38 @@ font-size: 0.8125rem; } +.image-picker-upload { + margin-bottom: 0.75rem; +} + +.image-picker-upload-label { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem; + border: 1px dashed var(--t-border-default); + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); + transition: border-color 100ms, color 100ms; + + @media (hover: hover) { + &:hover { + border-color: var(--t-text-primary); + color: var(--t-text-primary); + } + } +} + +.image-picker-item-no-alt { + color: var(--t-warning, #f59e0b); + position: absolute; + top: 0.25rem; + right: 0.25rem; +} + /* ═══════════════════════════════════════════════════════════════════ Media library ═══════════════════════════════════════════════════════════════════ */ diff --git a/lib/berrypod/legal_pages.ex b/lib/berrypod/legal_pages.ex index 1638388..1da046e 100644 --- a/lib/berrypod/legal_pages.ex +++ b/lib/berrypod/legal_pages.ex @@ -442,4 +442,46 @@ defmodule Berrypod.LegalPages do defp capitalise(<>), do: String.upcase(<>) <> rest defp capitalise(""), do: "" + + # ============================================================================= + # Page editor integration + # ============================================================================= + + @legal_slugs ~w(privacy delivery terms) + + @doc """ + Returns true if the given slug is a legal page that supports regeneration. + """ + def legal_slug?(slug), do: slug in @legal_slugs + + @doc """ + Generates plain text content for a legal page, suitable for the + `content_body` block's textarea field. + """ + def regenerate_legal_content("privacy"), do: rich_text_to_plain(privacy_content()) + def regenerate_legal_content("delivery"), do: rich_text_to_plain(delivery_content()) + def regenerate_legal_content("terms"), do: rich_text_to_plain(terms_content()) + + @doc """ + Converts rich text blocks (as returned by `*_content/0`) to a plain text + string with markdown-style headings. Double newlines separate sections. + """ + def rich_text_to_plain(blocks) when is_list(blocks) do + blocks + |> Enum.map(&block_to_text/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n\n") + end + + defp block_to_text(%{type: :lead, text: text}), do: text + defp block_to_text(%{type: :heading, text: text}), do: "## #{text}" + defp block_to_text(%{type: :paragraph, text: text}), do: text + + defp block_to_text(%{type: :list, items: items}) do + Enum.map_join(items, "\n", &"• #{&1}") + end + + defp block_to_text(%{type: :updated_at, date: date}), do: "Last updated: #{date}" + defp block_to_text(%{type: :closing, text: text}), do: text + defp block_to_text(_), do: "" end diff --git a/lib/berrypod_web/components/block_editor_components.ex b/lib/berrypod_web/components/block_editor_components.ex index c634eb6..20c5446 100644 --- a/lib/berrypod_web/components/block_editor_components.ex +++ b/lib/berrypod_web/components/block_editor_components.ex @@ -471,6 +471,7 @@ defmodule BerrypodWeb.BlockEditorComponents do attr :images, :list, required: true attr :search, :string, required: true attr :event_prefix, :string, default: "" + attr :upload, :any, default: nil def image_picker(assigns) do search = String.downcase(assigns.search) @@ -511,6 +512,16 @@ defmodule BerrypodWeb.BlockEditorComponents do autofocus /> +
+
+ +
+
+

diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 438b441..45e2290 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -1,7 +1,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view - alias Berrypod.{Media, Pages} + alias Berrypod.{LegalPages, Media, Pages} alias Berrypod.Pages.{BlockEditor, BlockTypes, Page} alias Berrypod.Products.ProductImage alias Berrypod.Theme.{Fonts, PreviewData} @@ -41,7 +41,98 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:image_picker_field_key, nil) |> assign(:image_picker_images, []) |> assign(:image_picker_search, "") - |> assign(:is_custom_page, !Page.system_slug?(slug))} + |> assign(:is_custom_page, !Page.system_slug?(slug)) + |> assign(:is_legal_page, LegalPages.legal_slug?(slug)) + |> allow_upload(:image_picker_upload, + accept: ~w(.png .jpg .jpeg .webp .svg), + max_entries: 1, + max_file_size: 5_000_000, + auto_upload: true, + progress: &handle_image_picker_upload/3 + ) + |> assign_content_source(slug, page.blocks)} + end + + defp handle_image_picker_upload(:image_picker_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :image_picker_upload, fn %{path: path}, entry -> + case Media.upload_from_entry(path, entry, "media") do + {:ok, image} -> {:ok, image} + {:error, _} = error -> error + end + end) + |> case do + [{:ok, image}] -> + block_id = socket.assigns.image_picker_block_id + field_key = socket.assigns.image_picker_field_key + + case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{ + field_key => image.id + }) do + {:ok, new_blocks} -> + {:noreply, + socket + |> apply_mutation(new_blocks, "Image uploaded and selected") + |> assign(:image_picker_block_id, nil) + |> assign(:image_picker_field_key, nil)} + + :noop -> + {:noreply, socket} + end + + _ -> + {:noreply, put_flash(socket, :error, "Upload failed")} + end + else + {:noreply, socket} + end + end + + # Determines whether legal page content is auto-generated or customised. + # For non-legal pages, content_source is nil (not shown). + # On mount, auto-populates empty legal pages with generated content. + defp assign_content_source(socket, slug, blocks) do + if LegalPages.legal_slug?(slug) do + current_content = get_content_body_text(blocks) + fresh_content = LegalPages.regenerate_legal_content(slug) + + cond do + # Empty content — auto-populate on mount + current_content == "" -> + blocks = populate_content_body(blocks, fresh_content) + + socket + |> assign(:blocks, blocks) + |> assign(:content_source, :auto) + + # Content matches generated — it's auto + current_content == fresh_content -> + assign(socket, :content_source, :auto) + + # Content differs — it's been customised + true -> + assign(socket, :content_source, :custom) + end + else + assign(socket, :content_source, nil) + end + end + + defp populate_content_body(blocks, content) do + Enum.map(blocks, fn + %{"type" => "content_body", "settings" => settings} = block -> + %{block | "settings" => Map.put(settings || %{}, "content", content)} + + block -> + block + end) + end + + defp get_content_body_text(blocks) do + case Enum.find(blocks, &(&1["type"] == "content_body")) do + %{"settings" => %{"content" => content}} when is_binary(content) -> content + _ -> "" + end end # ── Block manipulation events ──────────────────────────────────── @@ -101,7 +192,17 @@ defmodule BerrypodWeb.Admin.Pages.Editor do case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do {:ok, new_blocks} -> - {:noreply, apply_mutation(socket, new_blocks, "Settings updated")} + socket = apply_mutation(socket, new_blocks, "Settings updated") + + # Update content source badge if a legal page content block was edited + socket = + if socket.assigns.is_legal_page do + assign_content_source(socket, socket.assigns.slug, new_blocks) + else + socket + end + + {:noreply, socket} :noop -> {:noreply, socket} @@ -202,9 +303,30 @@ defmodule BerrypodWeb.Admin.Pages.Editor do %{"block-id" => block_id, "field" => field_key}, socket ) do + # Grab the old image_id before clearing + old_image_id = + case Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) do + %{"settings" => settings} -> settings[field_key] + _ -> nil + end + case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do {:ok, new_blocks} -> - {:noreply, apply_mutation(socket, new_blocks, "Image cleared")} + socket = apply_mutation(socket, new_blocks, "Image cleared") + + socket = + if is_binary(old_image_id) && old_image_id != "" && + Media.find_usages(old_image_id) == [] do + put_flash( + socket, + :info, + "Image removed — it's no longer used anywhere and can be deleted from the media library" + ) + else + socket + end + + {:noreply, socket} :noop -> {:noreply, socket} @@ -267,6 +389,29 @@ defmodule BerrypodWeb.Admin.Pages.Editor do end end + def handle_event("regenerate_legal", _params, socket) do + slug = socket.assigns.slug + content = LegalPages.regenerate_legal_content(slug) + + case Enum.find(socket.assigns.blocks, &(&1["type"] == "content_body")) do + %{"id" => block_id} -> + case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{"content" => content}) do + {:ok, new_blocks} -> + {:noreply, + socket + |> apply_mutation(new_blocks, "Legal content regenerated") + |> assign(:content_source, :auto) + |> put_flash(:info, "Content regenerated from current settings")} + + :noop -> + {:noreply, socket} + end + + nil -> + {:noreply, put_flash(socket, :error, "No content block found on this page")} + end + end + def handle_event("reset_defaults", _params, socket) do slug = socket.assigns.slug :ok = Pages.reset_page(slug) @@ -365,6 +510,14 @@ defmodule BerrypodWeb.Admin.Pages.Editor do > <.icon name="hero-arrow-uturn-right" class="size-4" /> +

+
+ +

+ Missing alt text — add a description for accessibility +

+
<% end %> @@ -449,6 +472,29 @@ × +
+ +

+ Missing alt text — add a description for accessibility +

+
diff --git a/test/berrypod/legal_pages_test.exs b/test/berrypod/legal_pages_test.exs index 508e93a..f48586e 100644 --- a/test/berrypod/legal_pages_test.exs +++ b/test/berrypod/legal_pages_test.exs @@ -252,4 +252,96 @@ defmodule Berrypod.LegalPagesTest do assert List.last(blocks).type == :closing end end + + # --------------------------------------------------------------------------- + # regenerate_legal_content/1 + # --------------------------------------------------------------------------- + + describe "regenerate_legal_content/1" do + test "returns a non-empty string for privacy" do + result = LegalPages.regenerate_legal_content("privacy") + assert is_binary(result) + assert String.length(result) > 100 + end + + test "returns a non-empty string for delivery" do + result = LegalPages.regenerate_legal_content("delivery") + assert is_binary(result) + assert String.length(result) > 100 + end + + test "returns a non-empty string for terms" do + result = LegalPages.regenerate_legal_content("terms") + assert is_binary(result) + assert String.length(result) > 100 + end + end + + # --------------------------------------------------------------------------- + # rich_text_to_plain/1 + # --------------------------------------------------------------------------- + + describe "rich_text_to_plain/1" do + test "converts headings to markdown-style" do + blocks = [%{type: :heading, text: "Hello"}] + assert LegalPages.rich_text_to_plain(blocks) == "## Hello" + end + + test "converts paragraphs as-is" do + blocks = [%{type: :paragraph, text: "Some text here."}] + assert LegalPages.rich_text_to_plain(blocks) == "Some text here." + end + + test "converts lists to bullet points" do + blocks = [%{type: :list, items: ["One", "Two", "Three"]}] + assert LegalPages.rich_text_to_plain(blocks) == "• One\n• Two\n• Three" + end + + test "converts lead blocks" do + blocks = [%{type: :lead, text: "Important intro"}] + assert LegalPages.rich_text_to_plain(blocks) == "Important intro" + end + + test "converts updated_at blocks" do + blocks = [%{type: :updated_at, date: "28 February 2026"}] + assert LegalPages.rich_text_to_plain(blocks) == "Last updated: 28 February 2026" + end + + test "joins multiple blocks with double newlines" do + blocks = [ + %{type: :heading, text: "Title"}, + %{type: :paragraph, text: "Body text."} + ] + + assert LegalPages.rich_text_to_plain(blocks) == "## Title\n\nBody text." + end + + test "skips unknown block types" do + blocks = [ + %{type: :heading, text: "Title"}, + %{type: :unknown_thing, data: "whatever"}, + %{type: :paragraph, text: "Body."} + ] + + assert LegalPages.rich_text_to_plain(blocks) == "## Title\n\nBody." + end + end + + # --------------------------------------------------------------------------- + # legal_slug?/1 + # --------------------------------------------------------------------------- + + describe "legal_slug?/1" do + test "returns true for legal page slugs" do + assert LegalPages.legal_slug?("privacy") + assert LegalPages.legal_slug?("delivery") + assert LegalPages.legal_slug?("terms") + end + + test "returns false for non-legal slugs" do + refute LegalPages.legal_slug?("home") + refute LegalPages.legal_slug?("about") + refute LegalPages.legal_slug?("contact") + end + end end diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 467135f..6bf6170 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -1004,6 +1004,66 @@ defmodule BerrypodWeb.Admin.PagesTest do end end + describe "legal page editor" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "shows regenerate button for privacy page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + assert has_element?(view, "button[phx-click='regenerate_legal']", "Regenerate") + end + + test "shows regenerate button for delivery page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/delivery") + + assert has_element?(view, "button[phx-click='regenerate_legal']", "Regenerate") + end + + test "does not show regenerate button for home page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + refute has_element?(view, "button[phx-click='regenerate_legal']") + end + + test "shows auto-generated badge for legal pages on mount", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + assert has_element?(view, ".admin-badge-info", "Auto-generated from settings") + end + + test "auto-populates content_body on mount for legal pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + # The content_body block should be expanded to see the content + page = Pages.get_page("privacy") + content_body = Enum.find(page.blocks, &(&1["type"] == "content_body")) + render_click(view, "toggle_expand", %{"id" => content_body["id"]}) + + html = render(view) + # Should contain generated legal content + assert html =~ "What we collect" + end + + test "shows customised badge after manual edit", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + page = Pages.get_page("privacy") + content_body = Enum.find(page.blocks, &(&1["type"] == "content_body")) + + render_click(view, "toggle_expand", %{"id" => content_body["id"]}) + + render_change(view, "update_block_settings", %{ + "block_id" => content_body["id"], + "block_settings" => %{"content" => "Custom privacy content here"} + }) + + assert has_element?(view, ".admin-badge", "Customised") + refute has_element?(view, ".admin-badge-info", "Auto-generated from settings") + end + end + defp count_repeater_items(html) do Regex.scan(~r/class="repeater-item"/, html) |> length() end