add legal page editor integration and media library polish
Some checks failed
deploy / deploy (push) Has been cancelled

Legal pages (privacy, delivery, terms) now auto-populate content from
shop settings on mount, show auto-generated vs customised badges, and
have a regenerate button. Theme editor gains alt text fields for logo,
header, and icon images. Image picker in page builder now has an upload
button and alt text warning badges. Clearing unused image references
shows an orphan info flash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 17:55:02 +00:00
parent 3336b3aa26
commit 93ff66debc
9 changed files with 495 additions and 11 deletions

View File

@ -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 | | ~~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 | | ~~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 | | ~~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** | | | | | | **Platform site** | | | |
| 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned |
| 74 | Separation of concerns: platform site vs AGPL open source core | 73 | 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 See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan
### Page Editor ### 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:** **Stages:**
1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`) 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`) 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`) 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~~ 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:** **Key files created:**
- `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data)

View File

@ -1869,6 +1869,7 @@
} }
.image-picker-item { .image-picker-item {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -1927,6 +1928,38 @@
font-size: 0.8125rem; 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 Media library
*/ */

View File

@ -442,4 +442,46 @@ defmodule Berrypod.LegalPages do
defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> rest defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> rest
defp capitalise(""), do: "" 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 end

View File

@ -471,6 +471,7 @@ defmodule BerrypodWeb.BlockEditorComponents do
attr :images, :list, required: true attr :images, :list, required: true
attr :search, :string, required: true attr :search, :string, required: true
attr :event_prefix, :string, default: "" attr :event_prefix, :string, default: ""
attr :upload, :any, default: nil
def image_picker(assigns) do def image_picker(assigns) do
search = String.downcase(assigns.search) search = String.downcase(assigns.search)
@ -511,6 +512,16 @@ defmodule BerrypodWeb.BlockEditorComponents do
autofocus autofocus
/> />
<div :if={@upload} class="image-picker-upload">
<form phx-change="noop" phx-submit="noop">
<label class="image-picker-upload-label">
<.icon name="hero-arrow-up-tray" class="size-4" />
<span>Upload new image</span>
<.live_file_input upload={@upload} class="hidden" />
</label>
</form>
</div>
<div class="image-picker-grid"> <div class="image-picker-grid">
<button <button
:for={image <- @filtered_images} :for={image <- @filtered_images}
@ -532,6 +543,13 @@ defmodule BerrypodWeb.BlockEditorComponents do
/> />
<% end %> <% end %>
<span class="image-picker-item-name">{image.filename}</span> <span class="image-picker-item-name">{image.filename}</span>
<span
:if={!image.alt || image.alt == ""}
class="image-picker-item-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle-mini" class="size-3" />
</span>
</button> </button>
<p :if={@filtered_images == []} class="image-picker-empty"> <p :if={@filtered_images == []} class="image-picker-empty">

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.Admin.Pages.Editor do defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages} alias Berrypod.{LegalPages, Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page} alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData} alias Berrypod.Theme.{Fonts, PreviewData}
@ -41,7 +41,98 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_field_key, nil) |> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, []) |> assign(:image_picker_images, [])
|> assign(:image_picker_search, "") |> 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 end
# ── Block manipulation events ──────────────────────────────────── # ── 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 case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} -> {: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 -> :noop ->
{:noreply, socket} {:noreply, socket}
@ -202,9 +303,30 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
%{"block-id" => block_id, "field" => field_key}, %{"block-id" => block_id, "field" => field_key},
socket socket
) do ) 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 case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} -> {: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 -> :noop ->
{:noreply, socket} {:noreply, socket}
@ -267,6 +389,29 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end end
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 def handle_event("reset_defaults", _params, socket) do
slug = socket.assigns.slug slug = socket.assigns.slug
:ok = Pages.reset_page(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" /> <.icon name="hero-arrow-uturn-right" class="size-4" />
</button> </button>
<button
:if={@is_legal_page}
phx-click="regenerate_legal"
data-confirm="Regenerate this page's content from your current shop settings? Any manual edits will be replaced."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-arrow-path" class="size-4" /> Regenerate
</button>
<button <button
:if={!@is_custom_page} :if={!@is_custom_page}
phx-click="reset_defaults" phx-click="reset_defaults"
@ -388,10 +541,18 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{if @live_region_message, do: @live_region_message} {if @live_region_message, do: @live_region_message}
</div> </div>
<%!-- Unsaved changes indicator --%> <%!-- Status badges --%>
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4"> <div class="mt-4 flex gap-2 flex-wrap">
Unsaved changes <p :if={@dirty} class="admin-badge admin-badge-warning">
</p> Unsaved changes
</p>
<p :if={@content_source == :auto} class="admin-badge admin-badge-info">
Auto-generated from settings
</p>
<p :if={@content_source == :custom} class="admin-badge">
Customised
</p>
</div>
<div class="page-editor-container"> <div class="page-editor-container">
<%!-- Editor pane --%> <%!-- Editor pane --%>
@ -452,6 +613,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
:if={@image_picker_block_id} :if={@image_picker_block_id}
images={@image_picker_images} images={@image_picker_images}
search={@image_picker_search} search={@image_picker_search}
upload={@uploads.image_picker_upload}
/> />
</div> </div>
""" """

View File

@ -285,6 +285,36 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do
case Media.get_image(image_id) do
nil ->
{:noreply, socket}
image ->
{:ok, updated} = Media.update_image_metadata(image, %{alt: alt})
# Refresh the relevant assign so the template sees the new alt text
socket =
cond do
socket.assigns.logo_image && socket.assigns.logo_image.id == image_id ->
assign(socket, :logo_image, updated)
socket.assigns.header_image && socket.assigns.header_image.id == image_id ->
assign(socket, :header_image, updated)
socket.assigns[:icon_image] && socket.assigns.icon_image &&
socket.assigns.icon_image.id == image_id ->
assign(socket, :icon_image, updated)
true ->
socket
end
{:noreply, socket}
end
end
@impl true @impl true
def handle_event("remove_logo", _params, socket) do def handle_event("remove_logo", _params, socket) do
if logo = socket.assigns.logo_image do if logo = socket.assigns.logo_image do

View File

@ -172,6 +172,29 @@
× ×
</button> </button>
</div> </div>
<form
phx-change="update_image_alt"
phx-value-image-id={@logo_image.id}
class="mt-2"
>
<label class="flex items-center gap-2">
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@logo_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm flex-1"
phx-debounce="blur"
/>
</label>
<p
:if={!@logo_image.alt || @logo_image.alt == ""}
class="text-xs text-warning mt-1"
>
Missing alt text — add a description for accessibility
</p>
</form>
<% end %> <% end %>
</div> </div>
@ -449,6 +472,29 @@
× ×
</button> </button>
</div> </div>
<form
phx-change="update_image_alt"
phx-value-image-id={@header_image.id}
class="mt-2"
>
<label class="flex items-center gap-2">
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@header_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm flex-1"
phx-debounce="blur"
/>
</label>
<p
:if={!@header_image.alt || @header_image.alt == ""}
class="text-xs text-warning mt-1"
>
Missing alt text — add a description for accessibility
</p>
</form>
<!-- Header Image Controls --> <!-- Header Image Controls -->
<div class="mt-3 flex flex-col gap-3"> <div class="mt-3 flex flex-col gap-3">

View File

@ -252,4 +252,96 @@ defmodule Berrypod.LegalPagesTest do
assert List.last(blocks).type == :closing assert List.last(blocks).type == :closing
end end
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 end

View File

@ -1004,6 +1004,66 @@ defmodule BerrypodWeb.Admin.PagesTest do
end end
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 defp count_repeater_items(html) do
Regex.scan(~r/class="repeater-item"/, html) |> length() Regex.scan(~r/class="repeater-item"/, html) |> length()
end end