From 3336b3aa265d2ee364a51df196831efd085ee19e Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 28 Feb 2026 17:33:25 +0000 Subject: [PATCH] add page builder polish: utility blocks, templates, duplicate New block types: spacer, divider, button/CTA, video embed (YouTube, Vimeo with privacy-enhanced embeds, fallback for unknown URLs). Page templates (blank, content, landing) shown when creating custom pages. Duplicate page action on admin index with slug deduplication. Fix block picker on shop edit sidebar being cut off on mobile by accounting for bottom nav and making the grid scrollable. Co-Authored-By: Claude Opus 4.6 --- assets/css/admin/components.css | 89 ++++++++++++- assets/css/shop/components.css | 102 ++++++++++++++ lib/berrypod/pages.ex | 24 ++++ lib/berrypod/pages/block_types.ex | 67 ++++++++++ lib/berrypod/pages/defaults.ex | 56 ++++++++ .../live/admin/pages/custom_form.ex | 30 ++++- lib/berrypod_web/live/admin/pages/index.ex | 28 ++++ lib/berrypod_web/page_renderer.ex | 99 ++++++++++++++ test/berrypod/pages_test.exs | 69 ++++++++++ test/berrypod_web/page_renderer_test.exs | 124 ++++++++++++++++++ 10 files changed, 686 insertions(+), 2 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 519b084..277ec0b 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1197,10 +1197,26 @@ } } +.page-card-action { + display: flex; + align-items: center; + padding: 0 0.25rem; + color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); + cursor: pointer; + border: none; + background: none; + + @media (hover: hover) { + &:hover { + color: var(--t-text-primary); + } + } +} + .page-card-delete { display: flex; align-items: center; - padding: 0 0.75rem; + padding: 0 0.75rem 0 0.25rem; color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); cursor: pointer; border: none; @@ -1213,6 +1229,59 @@ } } +/* ── Template picker ── */ + +.template-picker { + margin-top: 1.5rem; + max-width: 32rem; +} + +.template-picker-label { + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: var(--t-text-primary); +} + +.template-picker-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; +} + +.template-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + border: 1px solid var(--t-border-default); + border-radius: var(--t-radius); + background: var(--t-surface-base); + cursor: pointer; + text-align: left; + color: var(--t-text-primary); + transition: border-color 0.15s; +} + +.template-card:hover { + border-color: var(--t-text-secondary); +} + +.template-card-selected { + border-color: var(--t-accent-button); + background: color-mix(in oklch, var(--t-accent-button) 8%, var(--t-surface-base)); +} + +.template-card-name { + font-weight: 600; + font-size: 0.875rem; +} + +.template-card-desc { + font-size: 0.75rem; + color: var(--t-text-secondary); +} + /* Page editor split layout */ .page-editor-container { @@ -1598,6 +1667,23 @@ margin-bottom: 0.5rem; } +/* Picker inside the editor sidebar — grid scrolls within a capped height */ +.page-editor-sidebar .block-picker-overlay { + position: static; + background: none; +} + +.page-editor-sidebar .block-picker { + border-radius: 0; + max-height: none; + padding: 0; +} + +.page-editor-sidebar .block-picker-grid { + max-height: 45dvh; + overflow-y: auto; +} + .page-editor-content { flex: 1; margin-left: 360px; @@ -1624,6 +1710,7 @@ .page-editor-sidebar { width: 85%; max-width: 360px; + padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 1rem); } .page-editor-content { diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index 8bca928..ff95c87 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -808,6 +808,108 @@ font-size: var(--t-text-small); } + /* ── Spacer block ── */ + + .block-spacer { + height: 3rem; + } + + .block-spacer[data-size="small"] { + height: 1.5rem; + } + + .block-spacer[data-size="large"] { + height: 5rem; + } + + .block-spacer[data-size="xlarge"] { + height: 8rem; + } + + /* ── Divider block ── */ + + .block-divider { + border: none; + border-top: 1px solid var(--t-border-default); + max-width: 80rem; + margin: 2rem auto; + padding: 0; + } + + .block-divider[data-style="dots"] { + border-top-style: dotted; + border-top-width: 3px; + } + + .block-divider[data-style="fade"] { + border-top: none; + height: 1px; + background: linear-gradient( + to right, + transparent, + var(--t-border-default) 20%, + var(--t-border-default) 80%, + transparent + ); + } + + /* ── Button block ── */ + + .block-button { + max-width: 80rem; + margin-inline: auto; + padding: 1rem; + text-align: center; + } + + .block-button[data-align="left"] { + text-align: left; + } + + .block-button[data-align="right"] { + text-align: right; + } + + /* ── Video embed block ── */ + + .video-embed { + position: relative; + aspect-ratio: 16 / 9; + border-radius: var(--t-radius); + overflow: hidden; + } + + .video-embed[data-ratio="4:3"] { + aspect-ratio: 4 / 3; + } + + .video-embed[data-ratio="1:1"] { + aspect-ratio: 1 / 1; + } + + .video-embed iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + + .video-embed-caption { + color: var(--t-text-secondary); + font-size: var(--t-text-small); + text-align: center; + margin-top: 0.5rem; + } + + .video-embed-fallback { + text-align: center; + padding: 2rem 1rem; + } + + .video-embed-fallback a { + color: var(--t-accent-button); + } + /* ── Announcement bar ── */ .announcement-bar { diff --git a/lib/berrypod/pages.ex b/lib/berrypod/pages.ex index 007d7b1..8f0865d 100644 --- a/lib/berrypod/pages.ex +++ b/lib/berrypod/pages.ex @@ -203,6 +203,30 @@ defmodule Berrypod.Pages do def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page} + @doc """ + Duplicates a custom page, creating a draft copy with a "-copy" slug suffix. + """ + def duplicate_custom_page(%Page{type: "custom"} = page) do + new_slug = find_available_slug(page.slug <> "-copy") + + create_custom_page(%{ + "slug" => new_slug, + "title" => page.title <> " (copy)", + "blocks" => page.blocks, + "published" => false, + "meta_description" => page.meta_description, + "show_in_nav" => false + }) + end + + defp find_available_slug(slug) do + if Repo.one(from p in Page, where: p.slug == ^slug, select: p.id) do + find_available_slug(slug <> "-2") + else + slug + end + end + @doc """ Resets a page to its default block list. Deletes the DB row (if any) and invalidates the cache. diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index d05e9b6..0cfda1a 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -147,6 +147,73 @@ defmodule Berrypod.Pages.BlockTypes do settings_schema: [], data_loader: :load_reviews }, + "spacer" => %{ + name: "Spacer", + icon: "hero-arrows-up-down", + allowed_on: :all, + settings_schema: [ + %SettingsField{ + key: "size", + label: "Size", + type: :select, + options: ~w(small medium large xlarge), + default: "medium" + } + ] + }, + "divider" => %{ + name: "Divider", + icon: "hero-minus", + allowed_on: :all, + settings_schema: [ + %SettingsField{ + key: "style", + label: "Style", + type: :select, + options: ~w(line dots fade), + default: "line" + } + ] + }, + "button" => %{ + name: "Button", + icon: "hero-cursor-arrow-rays", + allowed_on: :all, + settings_schema: [ + %SettingsField{key: "text", label: "Button text", type: :text, default: "Learn more"}, + %SettingsField{key: "href", label: "Link URL", type: :text, default: "/"}, + %SettingsField{ + key: "style", + label: "Style", + type: :select, + options: ~w(primary outline), + default: "primary" + }, + %SettingsField{ + key: "alignment", + label: "Alignment", + type: :select, + options: ~w(left centre right), + default: "centre" + } + ] + }, + "video_embed" => %{ + name: "Video embed", + icon: "hero-play", + allowed_on: :all, + settings_schema: [ + %SettingsField{key: "url", label: "Video URL", type: :text, default: ""}, + %SettingsField{key: "caption", label: "Caption", type: :text, default: ""}, + %SettingsField{ + key: "aspect_ratio", + label: "Aspect ratio", + type: :select, + options: ~w(16:9 4:3 1:1), + default: "16:9" + } + ] + }, # ── PDP blocks ────────────────────────────────────────────────── diff --git a/lib/berrypod/pages/defaults.ex b/lib/berrypod/pages/defaults.ex index ffec48d..f8382bc 100644 --- a/lib/berrypod/pages/defaults.ex +++ b/lib/berrypod/pages/defaults.ex @@ -206,6 +206,62 @@ defmodule Berrypod.Pages.Defaults do defp blocks(_slug), do: [] + # ── Page templates ───────────────────────────────────────────── + + @doc "Returns available templates for new custom pages." + def templates do + [ + %{key: "blank", label: "Blank", description: "Start from scratch"}, + %{key: "content", label: "Content page", description: "Hero banner + text content"}, + %{key: "landing", label: "Landing page", description: "Hero, image + text, products, CTA"} + ] + end + + @doc "Returns the starter blocks for a template key." + def template_blocks("content") do + [ + block("hero", %{ + "title" => "Page title", + "description" => "A short summary of this page", + "variant" => "page" + }), + block("content_body", %{ + "content" => "Start writing your content here." + }) + ] + end + + def template_blocks("landing") do + [ + block("hero", %{ + "title" => "Your headline here", + "description" => "A compelling description that makes visitors want to keep reading.", + "cta_text" => "Shop now", + "cta_href" => "/collections/all" + }), + block("image_text", %{ + "title" => "Tell your story", + "description" => + "Use this section to share what makes your products special, your process, or your inspiration." + }), + block("featured_products", %{ + "title" => "Featured products", + "product_count" => 4, + "layout" => "grid", + "card_variant" => "default" + }), + block("button", %{ + "text" => "View all products", + "href" => "/collections/all", + "style" => "outline", + "alignment" => "centre" + }), + block("newsletter_card") + ] + end + + def template_blocks(_), do: [] + # ── Helpers ───────────────────────────────────────────────────── defp block(type, settings \\ %{}) do diff --git a/lib/berrypod_web/live/admin/pages/custom_form.ex b/lib/berrypod_web/live/admin/pages/custom_form.ex index e363fca..97bba66 100644 --- a/lib/berrypod_web/live/admin/pages/custom_form.ex +++ b/lib/berrypod_web/live/admin/pages/custom_form.ex @@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do use BerrypodWeb, :live_view alias Berrypod.Pages - alias Berrypod.Pages.Page + alias Berrypod.Pages.{Defaults, Page} @impl true def mount(params, _session, socket) do @@ -16,6 +16,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do |> assign(:page_title, "New page") |> assign(:page_struct, %Page{}) |> assign(:slug_touched, false) + |> assign(:selected_template, "blank") |> assign(:form, to_form(changeset)) end @@ -53,12 +54,20 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do {:noreply, assign(socket, form: form, slug_touched: slug_touched)} end + @impl true + def handle_event("select_template", %{"key" => key}, socket) do + {:noreply, assign(socket, :selected_template, key)} + end + @impl true def handle_event("save", %{"page" => params}, socket) do save_page(socket, socket.assigns.live_action, params) end defp save_page(socket, :new, params) do + template_blocks = Defaults.template_blocks(socket.assigns.selected_template) + params = Map.put(params, "blocks", template_blocks) + case Pages.create_custom_page(params) do {:ok, page} -> {:noreply, @@ -120,6 +129,25 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do {if @live_action == :new, do: "New page", else: "Page settings"} +
+

Start from a template

+
+ +
+
+ <.form for={@form} id="custom-page-form" diff --git a/lib/berrypod_web/live/admin/pages/index.ex b/lib/berrypod_web/live/admin/pages/index.ex index c293166..5419f88 100644 --- a/lib/berrypod_web/live/admin/pages/index.ex +++ b/lib/berrypod_web/live/admin/pages/index.ex @@ -40,6 +40,26 @@ defmodule BerrypodWeb.Admin.Pages.Index do end end + @impl true + def handle_event("duplicate_custom_page", %{"slug" => slug}, socket) do + case Pages.get_page_struct(slug) do + %{type: "custom"} = page -> + case Pages.duplicate_custom_page(page) do + {:ok, copy} -> + {:noreply, + socket + |> assign(:custom_pages, Pages.list_custom_pages()) + |> put_flash(:info, "\"#{copy.title}\" created as draft")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to duplicate page")} + end + + _ -> + {:noreply, put_flash(socket, :error, "Page not found")} + end + end + @impl true def render(assigns) do ~H""" @@ -101,6 +121,14 @@ defmodule BerrypodWeb.Admin.Pages.Index do <.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" /> +