From 660fda928f1f9489a4acb5fe819e41eac70ccbcc Mon Sep 17 00:00:00 2001 From: jamey Date: Thu, 26 Feb 2026 21:15:01 +0000 Subject: [PATCH] add admin page editor with block reordering and management Stage 6 of the page builder: admin UI at /admin/pages for managing page layouts. Page list shows all 14 pages grouped by category. Editor supports reorder (up/down), add, remove, duplicate, save, and reset to defaults. DirtyGuard JS hook warns on unsaved changes. ARIA live regions announce block operations for screen readers. Also: regenerate admin icons (81 rules via mix task with @layer wrapping), add gen_smtp dep for SMTP email adapter, add :key to page renderer block loop for correct LiveView diffing. 1309 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 --- assets/css/admin/components.css | 256 ++++++++++++ assets/css/admin/icons.css | 356 ++++++++++++++++- assets/js/app.js | 19 +- .../components/layouts/admin.html.heex | 8 + lib/berrypod_web/live/admin/pages/editor.ex | 378 ++++++++++++++++++ lib/berrypod_web/live/admin/pages/index.ex | 75 ++++ lib/berrypod_web/page_renderer.ex | 2 +- lib/berrypod_web/router.ex | 2 + lib/mix/tasks/generate_admin_icons.ex | 4 + mix.exs | 1 + mix.lock | 2 + test/berrypod_web/live/admin/pages_test.exs | 300 ++++++++++++++ 12 files changed, 1397 insertions(+), 6 deletions(-) create mode 100644 lib/berrypod_web/live/admin/pages/editor.ex create mode 100644 lib/berrypod_web/live/admin/pages/index.ex create mode 100644 test/berrypod_web/live/admin/pages_test.exs diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index e6cf94b..21419a9 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1062,4 +1062,260 @@ border-top: 1px solid var(--t-surface-sunken, #e5e5e5); } +/* ── Page editor ── */ + +.page-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.page-group-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); + margin-bottom: 0.5rem; +} + +.page-group-cards { + display: flex; + flex-direction: column; + gap: 1px; + border: 1px solid var(--t-border-default); + border-radius: 0.5rem; + overflow: hidden; +} + +.page-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--t-surface-base); + text-decoration: none; + color: var(--t-text-primary); + transition: background 100ms; + + &:not(:last-child) { + border-bottom: 1px solid var(--t-border-default); + } + + @media (hover: hover) { + &:hover { + background: var(--t-surface-sunken); + } + } +} + +.page-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + background: var(--t-surface-sunken); + flex-shrink: 0; + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); +} + +.page-card-info { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.page-card-title { + font-size: 0.875rem; + font-weight: 500; +} + +.page-card-meta { + font-size: 0.75rem; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); +} + +.page-card-arrow { + flex-shrink: 0; + color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); +} + +/* Block list in editor */ + +.block-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.block-list-empty { + text-align: center; + padding: 2rem; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); + font-size: 0.875rem; + border: 1px dashed var(--t-border-default); + border-radius: 0.5rem; +} + +.block-card { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--t-border-default); + border-radius: 0.5rem; + background: var(--t-surface-base); + transition: box-shadow 150ms; + + &:focus-within { + box-shadow: 0 0 0 2px color-mix(in oklch, var(--t-text-primary) 20%, transparent); + } +} + +.block-card-position { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); + flex-shrink: 0; +} + +.block-card-icon { + display: flex; + align-items: center; + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); + flex-shrink: 0; +} + +.block-card-name { + flex: 1; + font-size: 0.875rem; + font-weight: 500; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.block-card-controls { + display: flex; + align-items: center; + gap: 0.125rem; + flex-shrink: 0; +} + +.block-remove-btn { + color: color-mix(in oklch, var(--t-status-error) 70%, transparent); + + @media (hover: hover) { + &:hover { + color: var(--t-status-error); + } + } +} + +/* Add block button */ + +.block-actions { + margin-top: 1rem; + display: flex; + justify-content: center; +} + +.block-add-btn { + border-style: dashed; +} + +/* Block picker */ + +.block-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 50; + display: flex; + align-items: flex-end; + justify-content: center; + + @media (min-width: 40em) { + align-items: center; + } +} + +.block-picker { + background: var(--t-surface-base); + border-radius: 0.75rem 0.75rem 0 0; + width: 100%; + max-height: 80vh; + overflow-y: auto; + padding: 1rem; + + @media (min-width: 40em) { + border-radius: 0.75rem; + max-width: 28rem; + } +} + +.block-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + + & h3 { + font-size: 1rem; + font-weight: 600; + } +} + +.block-picker-search { + width: 100%; + margin-bottom: 0.75rem; +} + +.block-picker-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.block-picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border: 1px solid var(--t-border-default); + border-radius: 0.375rem; + background: none; + font-size: 0.8125rem; + cursor: pointer; + text-align: left; + color: inherit; + transition: background 100ms; + + @media (hover: hover) { + &:hover { + background: var(--t-surface-sunken); + } + } +} + +.block-picker-empty { + grid-column: 1 / -1; + text-align: center; + padding: 1rem; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); + font-size: 0.8125rem; +} + } /* @layer admin */ diff --git a/assets/css/admin/icons.css b/assets/css/admin/icons.css index 9651e17..fba621f 100644 --- a/assets/css/admin/icons.css +++ b/assets/css/admin/icons.css @@ -74,6 +74,18 @@ height: 1.25rem; } +.hero-arrow-uturn-left { + --hero-arrow-uturn-left: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-arrow-uturn-left); + mask: var(--hero-arrow-uturn-left); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-arrow-uturn-left-mini { --hero-arrow-uturn-left-mini: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-arrow-uturn-left-mini); @@ -86,6 +98,18 @@ height: 1.25rem; } +.hero-arrow-uturn-right { + --hero-arrow-uturn-right: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-arrow-uturn-right); + mask: var(--hero-arrow-uturn-right); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-banknotes { --hero-banknotes: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-banknotes); @@ -122,6 +146,18 @@ height: 1.5rem; } +.hero-calculator { + --hero-calculator: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-calculator); + mask: var(--hero-calculator); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-chart-bar { --hero-chart-bar: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-chart-bar); @@ -134,6 +170,18 @@ height: 1.5rem; } +.hero-chat-bubble-left-right { + --hero-chat-bubble-left-right: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-chat-bubble-left-right); + mask: var(--hero-chat-bubble-left-right); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-check-badge { --hero-check-badge: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-check-badge); @@ -194,6 +242,18 @@ height: 1.25rem; } +.hero-chevron-right { + --hero-chevron-right: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-chevron-right); + mask: var(--hero-chevron-right); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-chevron-right-mini { --hero-chevron-right-mini: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-chevron-right-mini); @@ -218,6 +278,30 @@ height: 1.25rem; } +.hero-clipboard-document { + --hero-clipboard-document: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-clipboard-document); + mask: var(--hero-clipboard-document); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-clipboard-document-list { + --hero-clipboard-document-list: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-clipboard-document-list); + mask: var(--hero-clipboard-document-list); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-clock { --hero-clock: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-clock); @@ -290,6 +374,54 @@ height: 1.5rem; } +.hero-document { + --hero-document: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-document); + mask: var(--hero-document); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-document-duplicate-mini { + --hero-document-duplicate-mini: url('data:image/svg+xml;utf8,%20%20%20%20'); + -webkit-mask: var(--hero-document-duplicate-mini); + mask: var(--hero-document-duplicate-mini); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.25rem; + height: 1.25rem; +} + +.hero-document-text { + --hero-document-text: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-document-text); + mask: var(--hero-document-text); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-envelope { + --hero-envelope: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-envelope); + mask: var(--hero-envelope); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-exclamation-circle { --hero-exclamation-circle: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-exclamation-circle); @@ -374,6 +506,18 @@ height: 1.25rem; } +.hero-funnel { + --hero-funnel: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-funnel); + mask: var(--hero-funnel); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-home { --hero-home: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-home); @@ -410,6 +554,54 @@ height: 1.5rem; } +.hero-link { + --hero-link: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-link); + mask: var(--hero-link); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-lock-closed { + --hero-lock-closed: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-lock-closed); + mask: var(--hero-lock-closed); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-magnifying-glass { + --hero-magnifying-glass: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-magnifying-glass); + mask: var(--hero-magnifying-glass); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-megaphone { + --hero-megaphone: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-megaphone); + mask: var(--hero-megaphone); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-minus-circle-mini { --hero-minus-circle-mini: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-minus-circle-mini); @@ -470,6 +662,18 @@ height: 1.25rem; } +.hero-paper-airplane { + --hero-paper-airplane: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-paper-airplane); + mask: var(--hero-paper-airplane); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-paper-airplane-mini { --hero-paper-airplane-mini: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-paper-airplane-mini); @@ -518,6 +722,18 @@ height: 1.25rem; } +.hero-puzzle-piece { + --hero-puzzle-piece: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-puzzle-piece); + mask: var(--hero-puzzle-piece); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-question-mark-circle-mini { --hero-question-mark-circle-mini: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-question-mark-circle-mini); @@ -530,10 +746,34 @@ height: 1.25rem; } -.hero-rocket-launch { - --hero-rocket-launch: url('data:image/svg+xml;utf8,%20%20'); - -webkit-mask: var(--hero-rocket-launch); - mask: var(--hero-rocket-launch); +.hero-rocket-launch-mini { + --hero-rocket-launch-mini: url('data:image/svg+xml;utf8,%20%20%20%20'); + -webkit-mask: var(--hero-rocket-launch-mini); + mask: var(--hero-rocket-launch-mini); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.25rem; + height: 1.25rem; +} + +.hero-share { + --hero-share: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-share); + mask: var(--hero-share); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-shield-check { + --hero-shield-check: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-shield-check); + mask: var(--hero-shield-check); mask-repeat: no-repeat; background-color: currentColor; vertical-align: middle; @@ -554,6 +794,18 @@ height: 1.5rem; } +.hero-shopping-cart { + --hero-shopping-cart: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-shopping-cart); + mask: var(--hero-shopping-cart); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-signal { --hero-signal: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-signal); @@ -566,6 +818,42 @@ height: 1.5rem; } +.hero-squares-2x2 { + --hero-squares-2x2: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-squares-2x2); + mask: var(--hero-squares-2x2); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-squares-plus { + --hero-squares-plus: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-squares-plus); + mask: var(--hero-squares-plus); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-star { + --hero-star: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-star); + mask: var(--hero-star); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-sun-micro { --hero-sun-micro: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-sun-micro); @@ -578,6 +866,42 @@ height: 1rem; } +.hero-tag { + --hero-tag: url('data:image/svg+xml;utf8,%20%20%20%20'); + -webkit-mask: var(--hero-tag); + mask: var(--hero-tag); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-trash-mini { + --hero-trash-mini: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-trash-mini); + mask: var(--hero-trash-mini); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.25rem; + height: 1.25rem; +} + +.hero-truck { + --hero-truck: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-truck); + mask: var(--hero-truck); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-truck-mini { --hero-truck-mini: url('data:image/svg+xml;utf8,%20%20%20%20'); -webkit-mask: var(--hero-truck-mini); @@ -590,6 +914,30 @@ height: 1.25rem; } +.hero-user { + --hero-user: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-user); + mask: var(--hero-user); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + +.hero-users { + --hero-users: url('data:image/svg+xml;utf8,%20%20'); + -webkit-mask: var(--hero-users); + mask: var(--hero-users); + mask-repeat: no-repeat; + background-color: currentColor; + vertical-align: middle; + display: inline-block; + width: 1.5rem; + height: 1.5rem; +} + .hero-x-circle { --hero-x-circle: url('data:image/svg+xml;utf8,%20%20'); -webkit-mask: var(--hero-x-circle); diff --git a/assets/js/app.js b/assets/js/app.js index 5bb269e..1850ad6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -634,10 +634,27 @@ const ChartTooltip = { } } +// Warns before navigating away from pages with unsaved changes +const DirtyGuard = { + mounted() { + this._beforeUnload = (e) => { + if (this.el.dataset.dirty === "true") { + e.preventDefault() + e.returnValue = "" + } + } + window.addEventListener("beforeunload", this._beforeUnload) + }, + + destroyed() { + window.removeEventListener("beforeunload", this._beforeUnload) + } +} + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, - hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip}, + hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard}, }) // Show progress bar on live navigation and form submits diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 39bf749..15f58f9 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -94,6 +94,14 @@ <.icon name="hero-link" class="size-5" /> Providers +
  • + <.link + navigate={~p"/admin/pages"} + class={admin_nav_active?(@current_path, "/admin/pages")} + > + <.icon name="hero-document" class="size-5" /> Pages + +
  • <.link href={~p"/admin/theme"} diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex new file mode 100644 index 0000000..1756f05 --- /dev/null +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -0,0 +1,378 @@ +defmodule BerrypodWeb.Admin.Pages.Editor do + use BerrypodWeb, :live_view + + alias Berrypod.Pages + alias Berrypod.Pages.{BlockTypes, Defaults} + + @impl true + def mount(%{"slug" => slug}, _session, socket) do + page = Pages.get_page(slug) + allowed_blocks = BlockTypes.allowed_for(slug) + + {:ok, + socket + |> assign(:page_title, page.title) + |> assign(:slug, slug) + |> assign(:page_data, page) + |> assign(:blocks, page.blocks) + |> assign(:allowed_blocks, allowed_blocks) + |> assign(:dirty, false) + |> assign(:show_picker, false) + |> assign(:picker_filter, "") + |> assign(:live_region_message, nil)} + end + + @impl true + def handle_event("move_up", %{"id" => block_id}, socket) do + blocks = socket.assigns.blocks + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx && idx > 0 do + block = Enum.at(blocks, idx) + block_name = block_display_name(block) + new_pos = idx + + new_blocks = + blocks + |> List.delete_at(idx) + |> List.insert_at(idx - 1, block) + + {:noreply, + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true) + |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")} + else + {:noreply, socket} + end + end + + def handle_event("move_down", %{"id" => block_id}, socket) do + blocks = socket.assigns.blocks + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx && idx < length(blocks) - 1 do + block = Enum.at(blocks, idx) + block_name = block_display_name(block) + new_pos = idx + 2 + + new_blocks = + blocks + |> List.delete_at(idx) + |> List.insert_at(idx + 1, block) + + {:noreply, + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true) + |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")} + else + {:noreply, socket} + end + end + + def handle_event("remove_block", %{"id" => block_id}, socket) do + block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) + block_name = block_display_name(block) + new_blocks = Enum.reject(socket.assigns.blocks, &(&1["id"] == block_id)) + + {:noreply, + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true) + |> assign(:live_region_message, "#{block_name} removed")} + end + + def handle_event("duplicate_block", %{"id" => block_id}, socket) do + blocks = socket.assigns.blocks + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx do + original = Enum.at(blocks, idx) + + copy = %{ + "id" => Defaults.generate_block_id(), + "type" => original["type"], + "settings" => original["settings"] || %{} + } + + block_name = block_display_name(original) + new_blocks = List.insert_at(blocks, idx + 1, copy) + + {:noreply, + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true) + |> assign(:live_region_message, "#{block_name} duplicated")} + else + {:noreply, socket} + end + end + + def handle_event("show_picker", _params, socket) do + {:noreply, + socket + |> assign(:show_picker, true) + |> assign(:picker_filter, "")} + end + + def handle_event("hide_picker", _params, socket) do + {:noreply, assign(socket, :show_picker, false)} + end + + def handle_event("filter_picker", %{"value" => value}, socket) do + {:noreply, assign(socket, :picker_filter, value)} + end + + def handle_event("add_block", %{"type" => type}, socket) do + block_def = BlockTypes.get(type) + + if block_def do + # Build default settings from schema + default_settings = + block_def + |> Map.get(:settings_schema, []) + |> Enum.into(%{}, fn field -> {field.key, field.default} end) + + new_block = %{ + "id" => Defaults.generate_block_id(), + "type" => type, + "settings" => default_settings + } + + {:noreply, + socket + |> assign(:blocks, socket.assigns.blocks ++ [new_block]) + |> assign(:dirty, true) + |> assign(:show_picker, false) + |> assign(:live_region_message, "#{block_def.name} added")} + else + {:noreply, socket} + end + end + + def handle_event("save", _params, socket) do + %{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns + + case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do + {:ok, _page} -> + {:noreply, + socket + |> assign(:dirty, false) + |> put_flash(:info, "Page saved")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to save page")} + end + end + + def handle_event("reset_defaults", _params, socket) do + slug = socket.assigns.slug + :ok = Pages.reset_page(slug) + page = Pages.get_page(slug) + + {:noreply, + socket + |> assign(:blocks, page.blocks) + |> assign(:dirty, false) + |> put_flash(:info, "Page reset to defaults")} + end + + @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> + {@page_data.title} + <:actions> + + + + + + <%!-- ARIA live region for screen reader announcements --%> +
    + {if @live_region_message, do: @live_region_message} +
    + + <%!-- Unsaved changes indicator --%> +

    + Unsaved changes +

    + + <%!-- Block list --%> +
    + <.block_card + :for={{block, idx} <- Enum.with_index(@blocks)} + block={block} + idx={idx} + total={length(@blocks)} + /> + +
    +

    No blocks on this page yet.

    +
    +
    + + <%!-- Add block button --%> +
    + +
    + + <%!-- Block picker modal --%> + <.block_picker + :if={@show_picker} + allowed_blocks={@allowed_blocks} + filter={@picker_filter} + /> +
    + """ + end + + defp block_card(assigns) do + block_type = BlockTypes.get(assigns.block["type"]) + assigns = assign(assigns, :block_type, block_type) + + ~H""" +
    + {@idx + 1} + + + <.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" /> + + + + {(@block_type && @block_type.name) || @block["type"]} + + + + + + + + +
    + """ + end + + defp block_picker(assigns) do + filter = String.downcase(assigns.filter) + + filtered = + assigns.allowed_blocks + |> Enum.filter(fn {_type, def} -> + filter == "" or String.contains?(String.downcase(def.name), filter) + end) + |> Enum.sort_by(fn {_type, def} -> def.name end) + + assigns = assign(assigns, :filtered_blocks, filtered) + + ~H""" +
    +
    +
    +

    Add a block

    + +
    + + + +
    + + +

    + No matching blocks. +

    +
    +
    +
    + """ + end + + defp block_display_name(nil), do: "Block" + + defp block_display_name(block) do + case BlockTypes.get(block["type"]) do + %{name: name} -> name + _ -> block["type"] + end + end +end diff --git a/lib/berrypod_web/live/admin/pages/index.ex b/lib/berrypod_web/live/admin/pages/index.ex new file mode 100644 index 0000000..bd500ca --- /dev/null +++ b/lib/berrypod_web/live/admin/pages/index.ex @@ -0,0 +1,75 @@ +defmodule BerrypodWeb.Admin.Pages.Index do + use BerrypodWeb, :live_view + + alias Berrypod.Pages + + @page_groups [ + {"Marketing", ~w(home about contact)}, + {"Legal", ~w(delivery privacy terms)}, + {"Shop", ~w(collection pdp cart search)}, + {"Orders", ~w(checkout_success orders order_detail)}, + {"System", ~w(error)} + ] + + @impl true + def mount(_params, _session, socket) do + pages = Pages.list_pages() |> Map.new(&{&1.slug, &1}) + + {:ok, + socket + |> assign(:page_title, "Pages") + |> assign(:pages, pages) + |> assign(:page_groups, @page_groups)} + end + + @impl true + def render(assigns) do + ~H""" + <.header> + Pages + <:subtitle>Customise the layout and content of every page on your shop. + + +
    +
    +

    {group_name}

    +
    + <.link + :for={slug <- slugs} + navigate={~p"/admin/pages/#{slug}"} + class="page-card" + > + + <.icon name={page_icon(slug)} class="size-5" /> + + + {@pages[slug].title} + + {length(@pages[slug].blocks)} {if length(@pages[slug].blocks) == 1, + do: "block", + else: "blocks"} + + + <.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" /> + +
    +
    +
    + """ + end + + defp page_icon("home"), do: "hero-home" + defp page_icon("about"), do: "hero-user" + defp page_icon("contact"), do: "hero-envelope" + defp page_icon("delivery"), do: "hero-truck" + defp page_icon("privacy"), do: "hero-shield-check" + defp page_icon("terms"), do: "hero-document-text" + defp page_icon("collection"), do: "hero-tag" + defp page_icon("pdp"), do: "hero-cube" + defp page_icon("cart"), do: "hero-shopping-cart" + defp page_icon("search"), do: "hero-magnifying-glass" + defp page_icon("checkout_success"), do: "hero-check-circle" + defp page_icon("orders"), do: "hero-clipboard-document-list" + defp page_icon("order_detail"), do: "hero-clipboard-document" + defp page_icon("error"), do: "hero-exclamation-triangle" +end diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index e596436..d935320 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -35,7 +35,7 @@ defmodule BerrypodWeb.PageRenderer do error_page={@page.slug == "error"} >
    -
    +
    {render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
    diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index a1a769c..43b3ebb 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -229,6 +229,8 @@ defmodule BerrypodWeb.Router do live "/providers/:id/edit", Admin.Providers.Form, :edit live "/settings", Admin.Settings, :index live "/settings/email", Admin.EmailSettings, :index + live "/pages", Admin.Pages.Index, :index + live "/pages/:slug", Admin.Pages.Editor, :edit live "/redirects", Admin.Redirects, :index end diff --git a/lib/mix/tasks/generate_admin_icons.ex b/lib/mix/tasks/generate_admin_icons.ex index 6263142..2aea9a4 100644 --- a/lib/mix/tasks/generate_admin_icons.ex +++ b/lib/mix/tasks/generate_admin_icons.ex @@ -43,7 +43,11 @@ defmodule Mix.Tasks.GenerateAdminIcons do content = """ /* Generated by mix generate_admin_icons — do not edit by hand */ + @layer admin { + #{Enum.join(css_rules, "\n\n")} + + } /* @layer admin */ """ File.mkdir_p!(Path.dirname(@output_path)) diff --git a/mix.exs b/mix.exs index e9fb9d4..78af016 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule Berrypod.MixProject do compile: false, depth: 1}, {:swoosh, "~> 1.16"}, + {:gen_smtp, "~> 1.0"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 6726f65..00c217e 100644 --- a/mix.lock +++ b/mix.lock @@ -30,6 +30,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, @@ -61,6 +62,7 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stripity_stripe": {:hex, :stripity_stripe, "3.2.0", "07c27f5f2ac87006945b5c997b99d1210e009e380ea78d339d025b11c9c745f5", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uri_query, "~> 0.2.0", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "f797936a9e9538370bae7dc73d73eafd7e44ecdc95b71c88492c43f6df094cb0"}, diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs new file mode 100644 index 0000000..98636b4 --- /dev/null +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -0,0 +1,300 @@ +defmodule BerrypodWeb.Admin.PagesTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + + alias Berrypod.Pages + alias Berrypod.Pages.PageCache + + setup do + PageCache.invalidate_all() + user = user_fixture() + %{user: user} + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + {:error, redirect} = live(conn, ~p"/admin/pages") + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "page list" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "renders page list with groups", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/pages") + + assert html =~ "Pages" + assert html =~ "Marketing" + assert html =~ "Legal" + assert html =~ "Shop" + assert html =~ "Orders" + assert html =~ "System" + end + + test "shows all 14 pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + assert has_element?(view, ".page-card-title", "Home page") + assert has_element?(view, ".page-card-title", "About") + assert has_element?(view, ".page-card-title", "Contact") + assert has_element?(view, ".page-card-title", "Product page") + assert has_element?(view, ".page-card-title", "Error") + end + + test "shows block count per page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + # Home has 4 default blocks + assert has_element?(view, ".page-card-meta", "4 blocks") + end + + test "links to editor", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages") + + view + |> element("a[href='/admin/pages/home']") + |> render_click() + + assert_redirect(view, ~p"/admin/pages/home") + end + end + + describe "page editor" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "renders editor with blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + assert has_element?(view, ".block-card-name", "Hero banner") + assert has_element?(view, ".block-card-name", "Category navigation") + assert has_element?(view, ".block-card-name", "Featured products") + assert has_element?(view, ".block-card-name", "Image + text") + end + + test "shows position numbers", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + assert has_element?(view, ".block-card-position", "1") + assert has_element?(view, ".block-card-position", "4") + end + + test "shows back link", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + assert has_element?(view, "a[href='/admin/pages']", "Pages") + end + + test "move block up reorders blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + # Get the second block (category_nav) and move it up + page = Pages.get_page("home") + second_block = Enum.at(page.blocks, 1) + + render_click(view, "move_up", %{"id" => second_block["id"]}) + + # The ARIA live region announces the move + assert has_element?(view, "[aria-live='polite']", "Category navigation moved to position 1") + end + + test "move block down reorders blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + first_block = Enum.at(page.blocks, 0) + + render_click(view, "move_down", %{"id" => first_block["id"]}) + + # Hero banner should now be at position 2 + assert has_element?(view, "[aria-live='polite']", "Hero banner moved to position 2") + end + + test "move up disabled for first block", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + first_block = Enum.at(page.blocks, 0) + + assert has_element?( + view, + "button[phx-value-id='#{first_block["id"]}'][phx-click='move_up'][disabled]" + ) + end + + test "move down disabled for last block", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + last_block = Enum.at(page.blocks, -1) + + assert has_element?( + view, + "button[phx-value-id='#{last_block["id"]}'][phx-click='move_down'][disabled]" + ) + end + + test "remove block", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + block = Enum.at(page.blocks, 1) + + render_click(view, "remove_block", %{"id" => block["id"]}) + + refute has_element?(view, ".block-card-name", "Category navigation") + assert has_element?(view, ".block-card-position", "3") + refute has_element?(view, ".block-card-position", "4") + end + + test "duplicate block", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + block = Enum.at(page.blocks, 0) + + render_click(view, "duplicate_block", %{"id" => block["id"]}) + + # Should now have 5 blocks (position 5 exists) + assert has_element?(view, ".block-card-position", "5") + assert has_element?(view, "[aria-live='polite']", "Hero banner duplicated") + end + + test "add block via picker", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + render_click(view, "show_picker") + assert has_element?(view, ".block-picker") + + render_click(view, "add_block", %{"type" => "trust_badges"}) + + assert has_element?(view, ".block-card-name", "Trust badges") + refute has_element?(view, ".block-picker") + end + + test "picker filter narrows results", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + render_click(view, "show_picker") + render_keyup(view, "filter_picker", %{"value" => "hero"}) + + assert has_element?(view, ".block-picker-item", "Hero banner") + refute has_element?(view, ".block-picker-item", "Trust badges") + end + + test "picker only shows blocks allowed on page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + render_click(view, "show_picker") + + assert has_element?(view, ".block-picker-item", "Hero banner") + refute has_element?(view, ".block-picker-item", "Product hero") + refute has_element?(view, ".block-picker-item", "Cart items") + end + + test "save persists blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + block = Enum.at(page.blocks, 1) + render_click(view, "remove_block", %{"id" => block["id"]}) + + render_click(view, "save") + + assert render(view) =~ "Page saved" + + saved = Pages.get_page("home") + assert length(saved.blocks) == 3 + types = Enum.map(saved.blocks, & &1["type"]) + refute "category_nav" in types + end + + test "reset to defaults restores original blocks", %{conn: conn} do + {:ok, _} = + Pages.save_page("home", %{ + title: "Home page", + blocks: [%{"id" => "blk_test", "type" => "hero", "settings" => %{}}] + }) + + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + # Should show only 1 block + assert has_element?(view, ".block-card-position", "1") + refute has_element?(view, ".block-card-position", "2") + + render_click(view, "reset_defaults") + + assert render(view) =~ "Page reset to defaults" + # Should now have 4 default blocks + assert has_element?(view, ".block-card-position", "4") + end + + test "dirty flag appears after changes", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + refute has_element?(view, ".admin-badge-warning", "Unsaved changes") + + page = Pages.get_page("home") + block = Enum.at(page.blocks, 0) + render_click(view, "move_down", %{"id" => block["id"]}) + + assert has_element?(view, ".admin-badge-warning", "Unsaved changes") + end + + test "dirty flag clears after save", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + block = Enum.at(page.blocks, 0) + render_click(view, "move_down", %{"id" => block["id"]}) + assert has_element?(view, ".admin-badge-warning") + + render_click(view, "save") + refute has_element?(view, ".admin-badge-warning") + end + + test "save button disabled when not dirty", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + assert has_element?(view, "button[disabled]", "Save") + end + end + + describe "page editor for page-specific pages" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "PDP editor shows PDP blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/pdp") + + assert has_element?(view, ".block-card-name", "Breadcrumb") + assert has_element?(view, ".block-card-name", "Product hero") + assert has_element?(view, ".block-card-name", "Trust badges") + end + + test "PDP picker shows PDP-specific blocks", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/pdp") + + render_click(view, "show_picker") + + assert has_element?(view, ".block-picker-item", "Product hero") + assert has_element?(view, ".block-picker-item", "Hero banner") + refute has_element?(view, ".block-picker-item", "Cart items") + end + + test "error page editor works", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/error") + + assert has_element?(view, ".block-card-name", "Hero banner") + assert has_element?(view, ".block-card-name", "Featured products") + end + end +end