Files
berrypod/lib/berrypod_web/live/admin/pages/editor.ex
jamey 4aa7dece0c
All checks were successful
deploy / deploy (push) Successful in 4m59s
add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
- Per-page SEO controls: meta robots directives, focus keyword, OG image
- Site-wide default OG image in admin settings
- FAQ block type with FAQPage JSON-LD schema
- Enhanced Organization JSON-LD with business info, contact, address
- Image sitemap with product images
- SEO preview panel with Google/social card mockups
- SEO checklist with real-time scoring
- Business info section in site editor
- GSC integration scaffolding (OAuth, client, cache)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-17 16:47:43 +01:00

1114 lines
36 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{LegalPages, Media, Pages, Products, Site}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.Components.SeoChecklist
import BerrypodWeb.Components.SeoPreview
@impl true
def mount(%{"slug" => slug}, _session, socket) do
page = Pages.get_page(slug)
allowed_blocks = BlockTypes.allowed_for(slug)
real_products = Products.list_visible_products(limit: 8)
real_categories = Products.list_categories()
preview_data = %{
products: if(real_products != [], do: real_products, else: PreviewData.products()),
categories: if(real_categories != [], do: real_categories, else: PreviewData.categories()),
cart_items: PreviewData.cart_items()
}
{:ok,
socket
|> assign(:page_title, page.title)
|> assign(:slug, slug)
|> assign(:page_data, page)
|> assign(:blocks, page.blocks)
|> assign(:allowed_blocks, allowed_blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> assign(:save_status, :idle)
|> assign(:show_picker, false)
|> assign(:picker_filter, "")
|> assign(:expanded, MapSet.new())
|> assign(:live_region_message, nil)
|> assign(:show_preview, false)
|> assign(:preview_data, preview_data)
|> assign(:logo_image, Media.get_logo())
|> assign(:header_image, Media.get_header())
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))
|> assign(:is_legal_page, LegalPages.legal_slug?(slug))
|> assign_settings_form(slug)
|> allow_upload(:image_picker_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_image_picker_upload/3
)
|> assign_content_source(slug, page.blocks)}
end
defp handle_image_picker_upload(:image_picker_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :image_picker_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "media") do
{:ok, image} -> {:ok, image}
{:error, _} = error -> error
end
end)
|> case do
[{:ok, image}] ->
block_id = socket.assigns.image_picker_block_id
field_key = socket.assigns.image_picker_field_key
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{
field_key => image.id
}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> apply_mutation(new_blocks, "Image uploaded and selected")
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
:noop ->
{:noreply, socket}
end
_ ->
{:noreply, put_flash(socket, :error, "Upload failed")}
end
else
{:noreply, socket}
end
end
# Determines whether legal page content is auto-generated or customised.
# For non-legal pages, content_source is nil (not shown).
# On mount, auto-populates empty legal pages with generated content.
defp assign_content_source(socket, slug, blocks) do
if LegalPages.legal_slug?(slug) do
current_content = get_content_body_text(blocks)
fresh_content = LegalPages.regenerate_legal_content(slug)
cond do
# Empty content — auto-populate on mount
current_content == "" ->
blocks = populate_content_body(blocks, fresh_content)
socket
|> assign(:blocks, blocks)
|> assign(:content_source, :auto)
# Content matches generated — it's auto
current_content == fresh_content ->
assign(socket, :content_source, :auto)
# Content differs — it's been customised
true ->
assign(socket, :content_source, :custom)
end
else
assign(socket, :content_source, nil)
end
end
defp populate_content_body(blocks, content) do
Enum.map(blocks, fn
%{"type" => "content_body", "settings" => settings} = block ->
%{block | "settings" => Map.put(settings || %{}, "content", content)}
block ->
block
end)
end
defp get_content_body_text(blocks) do
case Enum.find(blocks, &(&1["type"] == "content_body")) do
%{"settings" => %{"content" => content}} when is_binary(content) -> content
_ -> ""
end
end
# ── Block manipulation events ────────────────────────────────────
@impl true
def handle_event("move_up", %{"id" => block_id}, socket) do
case BlockEditor.move_up(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("move_down", %{"id" => block_id}, socket) do
case BlockEditor.move_down(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("remove_block", %{"id" => block_id}, socket) do
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
{:noreply, apply_mutation(socket, new_blocks, message)}
end
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("add_block", %{"type" => type}, socket) do
case BlockEditor.add_block(socket.assigns.blocks, type) do
{:ok, new_blocks, message} ->
{:noreply,
socket
|> apply_mutation(new_blocks, message)
|> assign(:show_picker, false)}
:noop ->
{:noreply, socket}
end
end
def handle_event("update_block_settings", params, socket) do
block_id = params["block_id"]
new_settings = params["block_settings"] || %{}
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} ->
socket = apply_mutation(socket, new_blocks, "Settings updated")
# Update content source badge if a legal page content block was edited
socket =
if socket.assigns.is_legal_page do
assign_content_source(socket, socket.assigns.slug, new_blocks)
else
socket
end
{:noreply, socket}
:noop ->
{:noreply, socket}
end
end
# ── Repeater events ──────────────────────────────────────────────
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
case BlockEditor.repeater_add(socket.assigns.blocks, block_id, field_key) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"repeater_remove",
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_remove(socket.assigns.blocks, block_id, field_key, index) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"repeater_move",
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_move(socket.assigns.blocks, block_id, field_key, index, dir) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
# ── Image picker events ──────────────────────────────────────────
def handle_event(
"show_image_picker",
%{"block-id" => block_id, "field" => field_key},
socket
) do
images = Media.list_images()
{:noreply,
socket
|> assign(:image_picker_block_id, block_id)
|> assign(:image_picker_field_key, field_key)
|> assign(:image_picker_images, images)
|> assign(:image_picker_search, "")}
end
def handle_event("hide_image_picker", _params, socket) do
{:noreply,
socket
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
end
def handle_event("image_picker_search", %{"value" => value}, socket) do
{:noreply, assign(socket, :image_picker_search, value)}
end
def handle_event("pick_image", %{"image-id" => image_id}, socket) do
block_id = socket.assigns.image_picker_block_id
field_key = socket.assigns.image_picker_field_key
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => image_id}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> apply_mutation(new_blocks, "Image selected")
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"clear_image",
%{"block-id" => block_id, "field" => field_key},
socket
) do
# Grab the old image_id before clearing
old_image_id =
case Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) do
%{"settings" => settings} -> settings[field_key]
_ -> nil
end
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} ->
socket = apply_mutation(socket, new_blocks, "Image cleared")
socket =
if is_binary(old_image_id) && old_image_id != "" &&
Media.find_usages(old_image_id) == [] do
put_flash(
socket,
:info,
"Image removed — it's no longer used anywhere and can be deleted from the media library"
)
else
socket
end
{:noreply, socket}
:noop ->
{:noreply, socket}
end
end
# ── UI events ────────────────────────────────────────────────────
def handle_event("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.expanded
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
block_name = BlockEditor.block_display_name(block)
{new_expanded, action} =
if MapSet.member?(expanded, block_id) do
{MapSet.delete(expanded, block_id), "collapsed"}
else
{MapSet.put(expanded, block_id), "expanded"}
end
{:noreply,
socket
|> assign(:expanded, new_expanded)
|> assign(:live_region_message, "#{block_name} settings #{action}")}
end
def handle_event("show_picker", _params, socket) do
{:noreply,
socket
|> assign(:show_picker, true)
|> assign(:picker_filter, "")}
end
def handle_event("hide_picker", _params, socket) do
{:noreply, assign(socket, :show_picker, false)}
end
def handle_event("filter_picker", %{"value" => value}, socket) do
{:noreply, assign(socket, :picker_filter, value)}
end
def handle_event("toggle_preview", _params, socket) do
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
end
# ── Page actions ─────────────────────────────────────────────────
def handle_event("save", _params, socket) do
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
{:ok, _page} ->
{:noreply,
socket
|> assign(:dirty, false)
|> assign(:save_status, :saved)}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:save_status, :error)
|> put_flash(:error, "Failed to save page")}
end
end
def handle_event("regenerate_legal", _params, socket) do
slug = socket.assigns.slug
content = LegalPages.regenerate_legal_content(slug)
case Enum.find(socket.assigns.blocks, &(&1["type"] == "content_body")) do
%{"id" => block_id} ->
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{"content" => content}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> apply_mutation(new_blocks, "Legal content regenerated")
|> assign(:content_source, :auto)
|> put_flash(:info, "Content regenerated from current settings")}
:noop ->
{:noreply, socket}
end
nil ->
{:noreply, put_flash(socket, :error, "No content block found on this page")}
end
end
def handle_event("reset_defaults", _params, socket) do
slug = socket.assigns.slug
:ok = Pages.reset_page(slug)
page = Pages.get_page(slug)
{:noreply,
socket
|> assign(:blocks, page.blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")}
end
def handle_event("undo", _params, socket) do
case socket.assigns.history do
[prev | rest] ->
future = [socket.assigns.blocks | socket.assigns.future]
{:noreply,
socket
|> assign(:blocks, prev)
|> assign(:history, rest)
|> assign(:future, future)
|> assign(:dirty, prev != socket.assigns.page_data.blocks)
|> assign(:live_region_message, "Undone")}
[] ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case socket.assigns.future do
[next | rest] ->
history = [socket.assigns.blocks | socket.assigns.history]
{:noreply,
socket
|> assign(:blocks, next)
|> assign(:history, history)
|> assign(:future, rest)
|> assign(:dirty, true)
|> assign(:live_region_message, "Redone")}
[] ->
{:noreply, socket}
end
end
# ── Page settings (custom pages only) ─────────────────────────────
def handle_event("toggle_settings", _params, socket) do
{:noreply, assign(socket, :show_settings, !socket.assigns.show_settings)}
end
def handle_event("validate_settings", %{"page" => params}, socket) do
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :settings_form, form)}
end
def handle_event("save_settings", %{"page" => params}, socket) do
case Pages.update_custom_page(socket.assigns.page_struct, params) do
{:ok, page} ->
{:noreply,
socket
|> assign(:page_struct, page)
|> assign(:page_data, %{socket.assigns.page_data | title: page.title})
|> assign(:page_title, page.title)
|> assign(:settings_form, to_form(Page.custom_changeset(page, %{})))
|> assign(:show_settings, false)
|> put_flash(:info, "Page settings saved")}
{:error, changeset} ->
{:noreply, assign(socket, :settings_form, to_form(changeset))}
end
end
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images()
{:noreply,
socket
|> assign(:settings_og_picker_open, true)
|> assign(:settings_og_picker_images, images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, :settings_og_picker_open, false)}
end
def handle_event("pick_og_image", %{"image-id" => image_id}, socket) do
image = Media.get_image(image_id)
# Update the form with the new og_image_id
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", image_id)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, image)
|> assign(:settings_og_picker_open, false)}
end
def handle_event("clear_og_image", _params, socket) do
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", nil)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, nil)}
end
defp assign_settings_form(socket, slug) do
if Page.system_slug?(slug) do
socket
|> assign(:show_settings, false)
|> assign(:page_struct, nil)
|> assign(:settings_form, nil)
|> assign(:settings_og_image, nil)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
else
page_struct = Pages.get_page_struct(slug)
og_image =
if page_struct.og_image_id, do: Media.get_image(page_struct.og_image_id), else: nil
socket
|> assign(:show_settings, false)
|> assign(:page_struct, page_struct)
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|> assign(:settings_og_image, og_image)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
end
end
# ── Handle info ──────────────────────────────────────────────────
# ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link navigate={~p"/admin/pages"} class="admin-back-link">
&larr; Pages
</.link>
<.header>
{@page_data.title}
<:actions>
<button
phx-click="toggle_preview"
class="admin-btn admin-btn-sm admin-btn-ghost page-editor-toggle-preview"
>
<.icon
name={if @show_preview, do: "hero-pencil-square", else: "hero-eye"}
class="size-4"
/>
{if @show_preview, do: "Edit", else: "Preview"}
</button>
<button
:if={@is_custom_page}
phx-click="toggle_settings"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@show_settings && "admin-btn-active"
]}
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</button>
<button
phx-click="undo"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="redo"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button
:if={@is_legal_page}
phx-click="regenerate_legal"
data-confirm="Regenerate this page's content from your current shop settings? Any manual edits will be replaced."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-arrow-path" class="size-4" /> Regenerate
</button>
<button
:if={!@is_custom_page}
phx-click="reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset to defaults
</button>
<button
phx-click="save"
class="admin-btn admin-btn-sm admin-btn-primary"
disabled={!@dirty}
>
Save
</button>
<.inline_feedback status={@save_status} />
</:actions>
</.header>
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @live_region_message, do: @live_region_message}
</div>
<%!-- Status badges --%>
<div class="admin-editor-badges">
<p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes
</p>
<p :if={@content_source == :auto} class="admin-badge admin-badge-info">
Auto-generated from settings
</p>
<p :if={@content_source == :custom} class="admin-badge">
Customised
</p>
</div>
<%!-- Inline page settings (custom pages) --%>
<div :if={@show_settings && @settings_form} class="page-settings-panel">
<.form
for={@settings_form}
id="inline-page-settings"
phx-change="validate_settings"
phx-submit="save_settings"
>
<div class="page-settings-fields">
<.input field={@settings_form[:title]} label="Title" />
<.input field={@settings_form[:slug]} label="URL slug" />
<.input
field={@settings_form[:meta_description]}
type="textarea"
label="Meta description"
phx-no-feedback
/>
<.input
field={@settings_form[:meta_robots]}
type="select"
label="Search engine indexing"
options={meta_robots_options()}
/>
<.input
field={@settings_form[:focus_keyword]}
label="Focus keyword"
placeholder="e.g. handmade prints"
/>
<%!-- Social sharing image --%>
<div class="page-settings-og-image">
<label class="admin-label">Social sharing image</label>
<p class="admin-hint">Shown when this page is shared on social media</p>
<input
type="hidden"
name="page[og_image_id]"
value={@settings_og_image && @settings_og_image.id}
/>
<%= if @settings_og_image do %>
<div class="page-settings-og-preview">
<img
src={media_image_url(@settings_og_image, 400)}
alt="Social preview"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Select image
</button>
<% end %>
</div>
<%!-- SEO analysis section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO analysis
</summary>
<div class="page-settings-seo-content">
<.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} />
</div>
</details>
<%!-- SEO preview section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO preview
</summary>
<div class="page-settings-seo-content">
<.seo_preview
title={seo_preview_title(@settings_form, @site_name)}
description={seo_preview_description(@settings_form)}
url={seo_preview_url(@settings_form)}
og_image={seo_preview_og_image(@settings_og_image)}
site_name={@site_name}
/>
</div>
</details>
<div class="page-settings-row">
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
<.input
field={@settings_form[:show_in_nav]}
type="checkbox"
label="Show in navigation"
/>
</div>
</div>
<div class="page-settings-actions">
<.button type="submit" phx-disable-with="Saving..." class="admin-btn-sm">
Save settings
</.button>
<button
type="button"
phx-click="toggle_settings"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Cancel
</button>
</div>
</.form>
</div>
<div class="page-editor-container">
<%!-- Editor pane --%>
<div class={[
"page-editor-pane",
@show_preview && "page-editor-pane-hidden-mobile"
]}>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@blocks)}
block={block}
idx={idx}
total={length(@blocks)}
expanded={@expanded}
/>
<div :if={@blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
</div>
</div>
<%!-- Preview pane --%>
<div class={[
"page-editor-preview-pane",
!@show_preview && "page-editor-preview-hidden-mobile"
]}>
<.preview_pane
slug={@slug}
blocks={@blocks}
page_data={@page_data}
preview_data={@preview_data}
theme_settings={@theme_settings}
site_name={@site_name}
generated_css={@generated_css}
logo_image={@logo_image}
header_image={@header_image}
/>
</div>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@show_picker}
allowed_blocks={@allowed_blocks}
filter={@picker_filter}
/>
<%!-- Image picker modal --%>
<.image_picker
:if={@image_picker_block_id}
images={@image_picker_images}
search={@image_picker_search}
upload={@uploads.image_picker_upload}
/>
<%!-- OG image picker modal --%>
<.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} />
</div>
"""
end
defp og_image_picker(assigns) do
~H"""
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
<div class="admin-modal-header">
<h2>Select social sharing image</h2>
<button
type="button"
phx-click="hide_og_picker"
class="admin-modal-close"
aria-label="Close"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<p class="admin-modal-hint">
Recommended: 1200×630px for best results on social platforms
</p>
<div class="admin-modal-body">
<div class="image-picker-grid">
<button
:for={image <- @images}
type="button"
phx-click="pick_og_image"
phx-value-image-id={image.id}
class="image-picker-item"
>
<img
src={media_image_url(image, 200)}
alt={image.alt || ""}
loading="lazy"
/>
</button>
</div>
<%= if @images == [] do %>
<p class="image-picker-empty">No images in media library yet</p>
<% end %>
</div>
</div>
</div>
"""
end
# ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
preview =
assigns
|> assign(:page, page)
|> assign(:mode, :preview)
|> assign(:products, assigns.preview_data.products)
|> assign(:categories, assigns.preview_data.categories)
|> assign(:cart_items, PreviewData.cart_drawer_items())
|> assign(:cart_count, 2)
|> assign(:cart_subtotal, "£72.00")
|> assign(:cart_drawer_open, false)
|> assign(:header_nav_items, load_nav_items("header"))
|> assign(:footer_nav_items, load_nav_items("footer"))
|> preview_page_context(assigns.slug)
extra = Pages.load_block_data(page.blocks, preview)
assigns = assign(assigns, :preview, assign(preview, extra))
~H"""
<div
class="page-editor-preview themed"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<style>
<%= Phoenix.HTML.raw(Fonts.generate_all_font_faces(&BerrypodWeb.Endpoint.static_path/1)) %>
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
<BerrypodWeb.PageRenderer.render_page {@preview} />
</div>
"""
end
defp preview_page_context(assigns, "pdp") do
product = List.first(assigns.preview_data.products)
option_types = Map.get(product, :option_types) || []
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} =
case variants do
[first | _] -> {first.options, first}
[] -> {%{}, nil}
end
available_options =
Enum.reduce(option_types, %{}, fn opt, acc ->
values = Enum.map(opt.values, & &1.title)
Map.put(acc, opt.name, values)
end)
display_price =
if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
|> assign(:option_urls, %{})
end
defp preview_page_context(assigns, "cart") do
cart_items = assigns.preview_data.cart_items
subtotal =
Enum.reduce(cart_items, 0, fn item, acc ->
acc + item.product.cheapest_price * item.quantity
end)
assigns
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
end
defp preview_page_context(assigns, "about"),
do: assign(assigns, :content_blocks, PreviewData.about_content())
defp preview_page_context(assigns, "delivery"),
do: assign(assigns, :content_blocks, PreviewData.delivery_content())
defp preview_page_context(assigns, "privacy"),
do: assign(assigns, :content_blocks, PreviewData.privacy_content())
defp preview_page_context(assigns, "terms"),
do: assign(assigns, :content_blocks, PreviewData.terms_content())
defp preview_page_context(assigns, "error") do
assign(assigns, %{
error_code: "404",
error_title: "Page Not Found",
error_description:
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
})
end
defp preview_page_context(assigns, _slug), do: assigns
defp build_gallery_images(product) do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
end
# ── Helpers ──────────────────────────────────────────────────────
defp apply_mutation(socket, new_blocks, message) do
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
socket
|> assign(:blocks, new_blocks)
|> assign(:history, history)
|> assign(:future, [])
|> assign(:dirty, true)
|> assign(:save_status, :idle)
|> assign(:live_region_message, message)
end
# Load nav items from database for preview, falling back to defaults if empty
defp load_nav_items(location) do
case Site.nav_items_for_shop(location) do
[] -> default_nav_items(location)
items -> items
end
end
defp default_nav_items("header"), do: Site.default_header_nav()
defp default_nav_items("footer"), do: Site.default_footer_nav()
defp meta_robots_options do
[
{"Index this page (default)", "index, follow"},
{"Don't index this page", "noindex, follow"},
{"Index but don't follow links", "index, nofollow"},
{"Don't index or follow links", "noindex, nofollow"}
]
end
# SEO preview helpers — extract values from the live form
defp seo_preview_title(form, site_name) do
title = Phoenix.HTML.Form.input_value(form, :title) || ""
if title == "", do: site_name, else: "#{title} · #{site_name}"
end
defp seo_preview_description(form) do
Phoenix.HTML.Form.input_value(form, :meta_description) || ""
end
defp seo_preview_url(form) do
slug = Phoenix.HTML.Form.input_value(form, :slug) || ""
base = BerrypodWeb.Endpoint.url()
if slug == "", do: base, else: "#{base}/#{slug}"
end
# Build page map for SEO analysis
defp seo_analysis_page(form, blocks) do
%{
focus_keyword: Phoenix.HTML.Form.input_value(form, :focus_keyword),
title: Phoenix.HTML.Form.input_value(form, :title),
meta_description: Phoenix.HTML.Form.input_value(form, :meta_description),
slug: Phoenix.HTML.Form.input_value(form, :slug),
blocks: blocks
}
end
defp seo_preview_og_image(nil), do: nil
defp seo_preview_og_image(image) do
media_image_url(image, 400)
end
# Generate a URL for a media library image at the given width
defp media_image_url(nil, _width), do: nil
defp media_image_url(image, width) do
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
applicable_width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> Enum.find(&(&1 >= width))
"/image_cache/#{image.id}-#{applicable_width || width}.webp"
end
end
end