Some checks failed
deploy / deploy (push) Has been cancelled
Legal pages (privacy, delivery, terms) now auto-populate content from shop settings on mount, show auto-generated vs customised badges, and have a regenerate button. Theme editor gains alt text fields for logo, header, and icon images. Image picker in page builder now has an upload button and alt text warning badges. Clearing unused image references shows an orphan info flash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
754 lines
24 KiB
Elixir
754 lines
24 KiB
Elixir
defmodule BerrypodWeb.Admin.Pages.Editor do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.{LegalPages, Media, Pages}
|
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
|
alias Berrypod.Products.ProductImage
|
|
alias Berrypod.Theme.{Fonts, PreviewData}
|
|
|
|
import BerrypodWeb.BlockEditorComponents
|
|
|
|
@impl true
|
|
def mount(%{"slug" => slug}, _session, socket) do
|
|
page = Pages.get_page(slug)
|
|
allowed_blocks = BlockTypes.allowed_for(slug)
|
|
|
|
preview_data = %{
|
|
products: PreviewData.products(),
|
|
categories: 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(: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))
|
|
|> 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)
|
|
|> put_flash(:info, "Page saved")}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :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
|
|
|
|
# ── 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="text-sm font-normal text-base-content/60 hover:underline"
|
|
>
|
|
← 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>
|
|
<.link
|
|
:if={@is_custom_page}
|
|
navigate={~p"/admin/pages/#{@slug}/settings"}
|
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
|
>
|
|
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
|
</.link>
|
|
<button
|
|
phx-click="undo"
|
|
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
|
|
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", @future == [] && "opacity-30"]}
|
|
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", !@dirty && "opacity-50"]}
|
|
disabled={!@dirty}
|
|
>
|
|
Save
|
|
</button>
|
|
</: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="mt-4 flex gap-2 flex-wrap">
|
|
<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>
|
|
|
|
<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}
|
|
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}
|
|
/>
|
|
</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, BerrypodWeb.ThemeHook.default_header_nav())
|
|
|> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav())
|
|
|> 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(:live_region_message, message)
|
|
end
|
|
end
|