add page builder polish: utility blocks, templates, duplicate
All checks were successful
deploy / deploy (push) Successful in 1m24s

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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 17:33:25 +00:00
parent 69ccc625b2
commit 3336b3aa26
10 changed files with 686 additions and 2 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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.

View File

@ -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 ──────────────────────────────────────────────────

View File

@ -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

View File

@ -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"}
</.header>
<div :if={@live_action == :new} class="template-picker">
<p class="template-picker-label">Start from a template</p>
<div class="template-picker-cards">
<button
:for={tmpl <- Defaults.templates()}
type="button"
phx-click="select_template"
phx-value-key={tmpl.key}
class={[
"template-card",
assigns[:selected_template] == tmpl.key && "template-card-selected"
]}
>
<span class="template-card-name">{tmpl.label}</span>
<span class="template-card-desc">{tmpl.description}</span>
</button>
</div>
</div>
<.form
for={@form}
id="custom-page-form"

View File

@ -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
</span>
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
</.link>
<button
phx-click="duplicate_custom_page"
phx-value-slug={page.slug}
class="page-card-action"
aria-label={"Duplicate #{page.title}"}
>
<.icon name="hero-document-duplicate" class="size-4" />
</button>
<button
phx-click="delete_custom_page"
phx-value-slug={page.slug}

View File

@ -965,6 +965,87 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
# ── Utility blocks ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "spacer"}} = assigns) do
size = get_in(assigns.block, ["settings", "size"]) || "medium"
assigns = assign(assigns, :size, size)
~H"""
<div class="block-spacer" data-size={@size} aria-hidden="true"></div>
"""
end
defp render_block(%{block: %{"type" => "divider"}} = assigns) do
style = get_in(assigns.block, ["settings", "style"]) || "line"
assigns = assign(assigns, :style, style)
~H"""
<hr class="block-divider" data-style={@style} />
"""
end
defp render_block(%{block: %{"type" => "button"}} = assigns) do
settings = assigns.block["settings"] || %{}
assigns =
assigns
|> assign(:text, settings["text"] || "Learn more")
|> assign(:href, settings["href"] || "/")
|> assign(:btn_style, settings["style"] || "primary")
|> assign(:alignment, settings["alignment"] || "centre")
~H"""
<div class="block-button" data-align={@alignment}>
<.link
navigate={@href}
class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"}
>
{@text}
</.link>
</div>
"""
end
defp render_block(%{block: %{"type" => "video_embed"}} = assigns) do
settings = assigns.block["settings"] || %{}
url = settings["url"] || ""
caption = settings["caption"] || ""
aspect_ratio = settings["aspect_ratio"] || "16:9"
{provider, embed_url} = parse_video_url(url)
assigns =
assigns
|> assign(:embed_url, embed_url)
|> assign(:provider, provider)
|> assign(:caption, caption)
|> assign(:aspect_ratio, aspect_ratio)
|> assign(:raw_url, url)
~H"""
<div class="page-container">
<div :if={@provider != :unknown} class="video-embed" data-ratio={@aspect_ratio}>
<iframe
src={@embed_url}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
title={if @caption != "", do: @caption, else: "Embedded video"}
>
</iframe>
</div>
<p :if={@provider == :unknown && @raw_url != ""} class="video-embed-fallback">
<a href={@raw_url} target="_blank" rel="noopener noreferrer">
{if @caption != "", do: @caption, else: "Watch video"}
</a>
</p>
<p :if={@caption != "" && @provider != :unknown} class="video-embed-caption">{@caption}</p>
</div>
"""
end
# ── Fallback ────────────────────────────────────────────────────
defp render_block(assigns) do
@ -1073,6 +1154,24 @@ defmodule BerrypodWeb.PageRenderer do
end
end
@youtube_re ~r/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
@vimeo_re ~r/vimeo\.com\/(?:video\/)?(\d+)/
defp parse_video_url(url) when is_binary(url) do
cond do
match = Regex.run(@youtube_re, url) ->
{:youtube, "https://www.youtube-nocookie.com/embed/#{Enum.at(match, 1)}"}
match = Regex.run(@vimeo_re, url) ->
{:vimeo, "https://player.vimeo.com/video/#{Enum.at(match, 1)}?dnt=1"}
true ->
{:unknown, nil}
end
end
defp parse_video_url(_), do: {:unknown, nil}
def format_order_status("unfulfilled"), do: "Being prepared"
def format_order_status("submitted"), do: "Sent to printer"
def format_order_status("processing"), do: "In production"

View File

@ -564,4 +564,73 @@ defmodule Berrypod.PagesTest do
refute Page.reserved_path?("faq")
end
end
describe "templates" do
test "templates/0 returns available templates" do
templates = Defaults.templates()
assert length(templates) == 3
keys = Enum.map(templates, & &1.key)
assert "blank" in keys
assert "content" in keys
assert "landing" in keys
end
test "template_blocks/1 returns blocks for content template" do
blocks = Defaults.template_blocks("content")
types = Enum.map(blocks, & &1["type"])
assert types == ["hero", "content_body"]
end
test "template_blocks/1 returns blocks for landing template" do
blocks = Defaults.template_blocks("landing")
types = Enum.map(blocks, & &1["type"])
assert "hero" in types
assert "featured_products" in types
assert "button" in types
end
test "template_blocks/1 returns empty list for blank" do
assert Defaults.template_blocks("blank") == []
end
test "template blocks have unique IDs" do
for tmpl <- Defaults.templates() do
blocks = Defaults.template_blocks(tmpl.key)
ids = Enum.map(blocks, & &1["id"])
assert ids == Enum.uniq(ids), "duplicate IDs in #{tmpl.key} template"
end
end
end
describe "duplicate_custom_page/1" do
test "creates a draft copy with -copy slug" do
{:ok, original} =
Pages.create_custom_page(%{
"slug" => "test-original",
"title" => "Test Page",
"blocks" => [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
})
{:ok, copy} = Pages.duplicate_custom_page(original)
assert copy.slug == "test-original-copy"
assert copy.title == "Test Page (copy)"
assert copy.published == false
assert length(copy.blocks) == 1
assert hd(copy.blocks)["type"] == "hero"
end
test "deduplicates slug when copy already exists" do
{:ok, original} =
Pages.create_custom_page(%{"slug" => "dup-test", "title" => "Dup Test"})
{:ok, _first_copy} = Pages.duplicate_custom_page(original)
{:ok, second_copy} = Pages.duplicate_custom_page(original)
assert second_copy.slug == "dup-test-copy-2"
end
end
end

View File

@ -231,6 +231,130 @@ defmodule BerrypodWeb.PageRendererTest do
end
end
describe "utility blocks" do
setup do
on_exit(fn ->
import Ecto.Query
Berrypod.Repo.delete_all(from p in Berrypod.Pages.Page, where: p.slug == "home")
Berrypod.Pages.PageCache.invalidate_all()
end)
:ok
end
test "spacer block renders with size" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [%{"id" => "blk_sp", "type" => "spacer", "settings" => %{"size" => "large"}}]
})
html = render_page("home", %{products: []})
assert html =~ ~s(class="block-spacer")
assert html =~ ~s(data-size="large")
end
test "divider block renders with style" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [%{"id" => "blk_dv", "type" => "divider", "settings" => %{"style" => "dots"}}]
})
html = render_page("home", %{products: []})
assert html =~ ~s(class="block-divider")
assert html =~ ~s(data-style="dots")
end
test "button block renders themed link" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [
%{
"id" => "blk_btn",
"type" => "button",
"settings" => %{
"text" => "Shop now",
"href" => "/collections/all",
"style" => "primary",
"alignment" => "centre"
}
}
]
})
html = render_page("home", %{products: []})
assert html =~ "Shop now"
assert html =~ ~s(data-align="centre")
assert html =~ "themed-button"
end
test "video embed renders YouTube iframe" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [
%{
"id" => "blk_vid",
"type" => "video_embed",
"settings" => %{
"url" => "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"caption" => "Demo video"
}
}
]
})
html = render_page("home", %{products: []})
assert html =~ "youtube-nocookie.com/embed/dQw4w9WgXcQ"
assert html =~ "Demo video"
assert html =~ "video-embed"
end
test "video embed renders Vimeo iframe" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [
%{
"id" => "blk_vim",
"type" => "video_embed",
"settings" => %{"url" => "https://vimeo.com/123456789"}
}
]
})
html = render_page("home", %{products: []})
assert html =~ "player.vimeo.com/video/123456789"
end
test "video embed shows fallback link for unknown URLs" do
{:ok, _} =
Pages.save_page("home", %{
title: "Home",
blocks: [
%{
"id" => "blk_unk",
"type" => "video_embed",
"settings" => %{"url" => "https://example.com/video", "caption" => "My video"}
}
]
})
html = render_page("home", %{products: []})
assert html =~ "video-embed-fallback"
assert html =~ "My video"
refute html =~ "<iframe"
end
end
describe "format_order_status/1" do
test "maps status strings to display text" do
assert PageRenderer.format_order_status("unfulfilled") == "Being prepared"