diff --git a/PROGRESS.md b/PROGRESS.md index 90036a5..6069f8d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -458,7 +458,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan ### Page Editor -**Status:** In progress — Stage 7 of 9 complete, 1320 tests +**Status:** In progress — Stage 7b of 9 complete, 1326 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). @@ -469,7 +469,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅ 5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅ 6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`) -7. ~~Admin editor — inline block settings editing~~ ✅ +7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`) +7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) 8. **Next →** Live preview — split layout with real-time preview 9. Undo/redo + polish — history stacks, keyboard shortcuts, animations @@ -480,7 +481,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON - `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management) - `test/berrypod/pages_test.exs` — 34 tests - `test/berrypod_web/page_renderer_test.exs` — 18 tests -- `test/berrypod_web/live/admin/pages_test.exs` — 36 tests +- `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields +- `test/berrypod_web/live/admin/pages_test.exs` — 42 tests See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 7706229..ff7954f 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1144,6 +1144,74 @@ color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); } +/* Page editor split layout */ + +.page-editor-container { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.page-editor-pane-hidden-mobile { + display: none; +} + +.page-editor-preview-hidden-mobile { + display: none; +} + +.page-editor-preview-pane { + border: 1px solid color-mix(in oklch, var(--t-text-primary) 15%, transparent); + border-radius: 0.5rem; + overflow: hidden; +} + +.page-editor-preview { + transform: scale(0.55); + transform-origin: top left; + width: 181.82%; /* 1/0.55 */ + pointer-events: none; +} + +.page-editor-toggle-preview { + display: inline-flex; +} + +@media (min-width: 64em) { + .page-editor-container { + flex-direction: row; + gap: 1.5rem; + } + + .page-editor-pane { + flex: 1; + min-width: 0; + overflow-y: auto; + } + + .page-editor-pane-hidden-mobile { + display: block; + } + + .page-editor-preview-pane { + flex: 1; + min-width: 0; + max-height: calc(100vh - 14rem); + overflow-y: auto; + position: sticky; + top: 1rem; + } + + .page-editor-preview-hidden-mobile { + display: block; + } + + .page-editor-toggle-preview { + display: none; + } +} + /* Block list in editor */ .block-list { diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 0c59a81..c4153f4 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -1,14 +1,22 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view - alias Berrypod.Pages - alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField} + alias Berrypod.{Media, Pages} + alias Berrypod.Pages.{BlockTypes, Defaults} + alias Berrypod.Products.ProductImage + alias Berrypod.Theme.{Fonts, PreviewData} @impl true def mount(%{"slug" => slug}, _session, socket) do page = Pages.get_page(slug) allowed_blocks = BlockTypes.allowed_for(slug) + preview_data = %{ + products: PreviewData.products(), + categories: PreviewData.categories(), + cart_items: PreviewData.cart_items() + } + {:ok, socket |> assign(:page_title, page.title) @@ -20,7 +28,11 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:show_picker, false) |> assign(:picker_filter, "") |> assign(:expanded, MapSet.new()) - |> assign(:live_region_message, nil)} + |> assign(:live_region_message, nil) + |> assign(:show_preview, false) + |> assign(:preview_data, preview_data) + |> assign(:logo_image, Media.get_logo()) + |> assign(:header_image, Media.get_header())} end @impl true @@ -313,6 +325,10 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> put_flash(:info, "Page reset to defaults")} end + def handle_event("toggle_preview", _params, socket) do + {:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)} + end + @impl true def render(assigns) do ~H""" @@ -326,6 +342,16 @@ defmodule BerrypodWeb.Admin.Pages.Editor do <.header> {@page_data.title} <:actions> + + - - <%!-- Add block button --%> -
- + <%!-- Preview pane --%> +
+ <.preview_pane + slug={@slug} + blocks={@blocks} + page_data={@page_data} + preview_data={@preview_data} + theme_settings={@theme_settings} + generated_css={@generated_css} + logo_image={@logo_image} + header_image={@header_image} + /> +
<%!-- Block picker modal --%> @@ -385,6 +436,126 @@ defmodule BerrypodWeb.Admin.Pages.Editor do """ end + # ── Preview ─────────────────────────────────────────────────────── + + defp preview_pane(assigns) do + # Build a temporary page struct from working state + page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks} + + preview = + assigns + |> assign(:page, page) + |> assign(:mode, :preview) + |> assign(:products, assigns.preview_data.products) + |> assign(:categories, assigns.preview_data.categories) + |> assign(:cart_items, PreviewData.cart_drawer_items()) + |> assign(:cart_count, 2) + |> assign(:cart_subtotal, "£72.00") + |> assign(:cart_drawer_open, false) + |> preview_page_context(assigns.slug) + + extra = Pages.load_block_data(page.blocks, preview) + assigns = assign(assigns, :preview, assign(preview, extra)) + + ~H""" +
+ + +
+ """ + end + + defp preview_page_context(assigns, "pdp") do + product = List.first(assigns.preview_data.products) + option_types = Map.get(product, :option_types) || [] + variants = Map.get(product, :variants) || [] + + {selected_options, selected_variant} = + case variants do + [first | _] -> {first.options, first} + [] -> {%{}, nil} + end + + available_options = + Enum.reduce(option_types, %{}, fn opt, acc -> + values = Enum.map(opt.values, & &1.title) + Map.put(acc, opt.name, values) + end) + + display_price = + if selected_variant, do: selected_variant.price, else: product.cheapest_price + + assigns + |> assign(:product, product) + |> assign(:gallery_images, build_gallery_images(product)) + |> assign(:option_types, option_types) + |> assign(:selected_options, selected_options) + |> assign(:available_options, available_options) + |> assign(:display_price, display_price) + |> assign(:quantity, 1) + |> assign(:option_urls, %{}) + end + + defp preview_page_context(assigns, "cart") do + cart_items = assigns.preview_data.cart_items + + subtotal = + Enum.reduce(cart_items, 0, fn item, acc -> + acc + item.product.cheapest_price * item.quantity + end) + + assigns + |> assign(:cart_page_items, cart_items) + |> assign(:cart_page_subtotal, subtotal) + end + + defp preview_page_context(assigns, "about"), + do: assign(assigns, :content_blocks, PreviewData.about_content()) + + defp preview_page_context(assigns, "delivery"), + do: assign(assigns, :content_blocks, PreviewData.delivery_content()) + + defp preview_page_context(assigns, "privacy"), + do: assign(assigns, :content_blocks, PreviewData.privacy_content()) + + defp preview_page_context(assigns, "terms"), + do: assign(assigns, :content_blocks, PreviewData.terms_content()) + + defp preview_page_context(assigns, "error") do + assign(assigns, %{ + error_code: "404", + error_title: "Page Not Found", + error_description: + "Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved." + }) + end + + defp preview_page_context(assigns, _slug), do: assigns + + defp build_gallery_images(product) do + (Map.get(product, :images) || []) + |> Enum.sort_by(& &1.position) + |> Enum.map(fn img -> ProductImage.url(img, 1200) end) + |> Enum.reject(&is_nil/1) + end + + # ── Block card ───────────────────────────────────────────────────── + defp block_card(assigns) do block_type = BlockTypes.get(assigns.block["type"]) has_settings = has_settings?(assigns.block) diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 7ba3086..3389259 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -624,6 +624,83 @@ defmodule BerrypodWeb.Admin.PagesTest do end end + describe "live preview" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "preview pane renders page content", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/pages/home") + + # Preview pane should be in the DOM with the themed class + assert html =~ "page-editor-preview themed" + # Should render the page via PageRenderer (hero block is on home) + assert html =~ "page-editor-preview-pane" + end + + test "toggle preview switches between edit and preview on mobile", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + # Initially: editor visible, preview hidden on mobile + assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)") + assert has_element?(view, ".page-editor-preview-hidden-mobile") + + # Toggle to preview + render_click(view, "toggle_preview") + + assert has_element?(view, ".page-editor-pane-hidden-mobile") + + assert has_element?( + view, + ".page-editor-preview-pane:not(.page-editor-preview-hidden-mobile)" + ) + + # Toggle back to edit + render_click(view, "toggle_preview") + + assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)") + assert has_element?(view, ".page-editor-preview-hidden-mobile") + end + + test "preview updates when block settings change", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + + render_change(view, "update_block_settings", %{ + "block_id" => hero["id"], + "block_settings" => %{"title" => "Preview test title"} + }) + + # The preview should show the updated title + html = render(view) + assert html =~ "Preview test title" + end + + test "preview updates after block reorder", %{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"]}) + + # Should still render without errors + html = render(view) + assert html =~ "page-editor-preview themed" + end + + test "preview toggle button shows in header", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + # Toggle button should be present + assert has_element?(view, ".page-editor-toggle-preview") + end + end + defp count_repeater_items(html) do Regex.scan(~r/class="repeater-item"/, html) |> length() end