add page builder polish: utility blocks, templates, duplicate
All checks were successful
deploy / deploy (push) Successful in 1m24s
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:
parent
69ccc625b2
commit
3336b3aa26
@ -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 {
|
.page-card-delete {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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);
|
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
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 split layout */
|
||||||
|
|
||||||
.page-editor-container {
|
.page-editor-container {
|
||||||
@ -1598,6 +1667,23 @@
|
|||||||
margin-bottom: 0.5rem;
|
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 {
|
.page-editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 360px;
|
margin-left: 360px;
|
||||||
@ -1624,6 +1710,7 @@
|
|||||||
.page-editor-sidebar {
|
.page-editor-sidebar {
|
||||||
width: 85%;
|
width: 85%;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
|
padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-editor-content {
|
.page-editor-content {
|
||||||
|
|||||||
@ -808,6 +808,108 @@
|
|||||||
font-size: var(--t-text-small);
|
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 ── */
|
||||||
|
|
||||||
.announcement-bar {
|
.announcement-bar {
|
||||||
|
|||||||
@ -203,6 +203,30 @@ defmodule Berrypod.Pages do
|
|||||||
|
|
||||||
def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page}
|
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 """
|
@doc """
|
||||||
Resets a page to its default block list. Deletes the DB row (if any)
|
Resets a page to its default block list. Deletes the DB row (if any)
|
||||||
and invalidates the cache.
|
and invalidates the cache.
|
||||||
|
|||||||
@ -147,6 +147,73 @@ defmodule Berrypod.Pages.BlockTypes do
|
|||||||
settings_schema: [],
|
settings_schema: [],
|
||||||
data_loader: :load_reviews
|
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 ──────────────────────────────────────────────────
|
# ── PDP blocks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -206,6 +206,62 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
|
|
||||||
defp blocks(_slug), 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 ─────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp block(type, settings \\ %{}) do
|
defp block(type, settings \\ %{}) do
|
||||||
|
|||||||
@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
|
|||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias Berrypod.Pages.Page
|
alias Berrypod.Pages.{Defaults, Page}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
@ -16,6 +16,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
|
|||||||
|> assign(:page_title, "New page")
|
|> assign(:page_title, "New page")
|
||||||
|> assign(:page_struct, %Page{})
|
|> assign(:page_struct, %Page{})
|
||||||
|> assign(:slug_touched, false)
|
|> assign(:slug_touched, false)
|
||||||
|
|> assign(:selected_template, "blank")
|
||||||
|> assign(:form, to_form(changeset))
|
|> assign(:form, to_form(changeset))
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -53,12 +54,20 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
|
|||||||
{:noreply, assign(socket, form: form, slug_touched: slug_touched)}
|
{:noreply, assign(socket, form: form, slug_touched: slug_touched)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("select_template", %{"key" => key}, socket) do
|
||||||
|
{:noreply, assign(socket, :selected_template, key)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"page" => params}, socket) do
|
def handle_event("save", %{"page" => params}, socket) do
|
||||||
save_page(socket, socket.assigns.live_action, params)
|
save_page(socket, socket.assigns.live_action, params)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp save_page(socket, :new, params) do
|
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
|
case Pages.create_custom_page(params) do
|
||||||
{:ok, page} ->
|
{:ok, page} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
@ -120,6 +129,25 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
|
|||||||
{if @live_action == :new, do: "New page", else: "Page settings"}
|
{if @live_action == :new, do: "New page", else: "Page settings"}
|
||||||
</.header>
|
</.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
|
<.form
|
||||||
for={@form}
|
for={@form}
|
||||||
id="custom-page-form"
|
id="custom-page-form"
|
||||||
|
|||||||
@ -40,6 +40,26 @@ defmodule BerrypodWeb.Admin.Pages.Index do
|
|||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -101,6 +121,14 @@ defmodule BerrypodWeb.Admin.Pages.Index do
|
|||||||
</span>
|
</span>
|
||||||
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
|
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
|
||||||
</.link>
|
</.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
|
<button
|
||||||
phx-click="delete_custom_page"
|
phx-click="delete_custom_page"
|
||||||
phx-value-slug={page.slug}
|
phx-value-slug={page.slug}
|
||||||
|
|||||||
@ -965,6 +965,87 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
"""
|
"""
|
||||||
end
|
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 ────────────────────────────────────────────────────
|
# ── Fallback ────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp render_block(assigns) do
|
defp render_block(assigns) do
|
||||||
@ -1073,6 +1154,24 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
end
|
end
|
||||||
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("unfulfilled"), do: "Being prepared"
|
||||||
def format_order_status("submitted"), do: "Sent to printer"
|
def format_order_status("submitted"), do: "Sent to printer"
|
||||||
def format_order_status("processing"), do: "In production"
|
def format_order_status("processing"), do: "In production"
|
||||||
|
|||||||
@ -564,4 +564,73 @@ defmodule Berrypod.PagesTest do
|
|||||||
refute Page.reserved_path?("faq")
|
refute Page.reserved_path?("faq")
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -231,6 +231,130 @@ defmodule BerrypodWeb.PageRendererTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "format_order_status/1" do
|
||||||
test "maps status strings to display text" do
|
test "maps status strings to display text" do
|
||||||
assert PageRenderer.format_order_status("unfulfilled") == "Being prepared"
|
assert PageRenderer.format_order_status("unfulfilled") == "Being prepared"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user