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:
@@ -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.
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user