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" /> +