2026-02-26 21:15:01 +00:00
|
|
|
defmodule BerrypodWeb.Admin.Pages.Editor do
|
|
|
|
|
use BerrypodWeb, :live_view
|
|
|
|
|
|
2026-02-28 20:17:03 +00:00
|
|
|
alias Berrypod.{LegalPages, Media, Pages, Products}
|
2026-02-28 09:43:03 +00:00
|
|
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
2026-02-27 08:06:17 +00:00
|
|
|
alias Berrypod.Products.ProductImage
|
|
|
|
|
alias Berrypod.Theme.{Fonts, PreviewData}
|
2026-02-26 21:15:01 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
import BerrypodWeb.BlockEditorComponents
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
@impl true
|
|
|
|
|
def mount(%{"slug" => slug}, _session, socket) do
|
|
|
|
|
page = Pages.get_page(slug)
|
|
|
|
|
allowed_blocks = BlockTypes.allowed_for(slug)
|
|
|
|
|
|
2026-02-28 20:17:03 +00:00
|
|
|
real_products = Products.list_visible_products(limit: 8)
|
|
|
|
|
real_categories = Products.list_categories()
|
|
|
|
|
|
2026-02-27 08:06:17 +00:00
|
|
|
preview_data = %{
|
2026-02-28 20:17:03 +00:00
|
|
|
products: if(real_products != [], do: real_products, else: PreviewData.products()),
|
|
|
|
|
categories: if(real_categories != [], do: real_categories, else: PreviewData.categories()),
|
2026-02-27 08:06:17 +00:00
|
|
|
cart_items: PreviewData.cart_items()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
{:ok,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:page_title, page.title)
|
|
|
|
|
|> assign(:slug, slug)
|
|
|
|
|
|> assign(:page_data, page)
|
|
|
|
|
|> assign(:blocks, page.blocks)
|
|
|
|
|
|> assign(:allowed_blocks, allowed_blocks)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:history, [])
|
|
|
|
|
|> assign(:future, [])
|
2026-02-26 21:15:01 +00:00
|
|
|
|> assign(:dirty, false)
|
2026-03-07 21:01:54 +00:00
|
|
|
|> assign(:save_status, :idle)
|
2026-02-26 21:15:01 +00:00
|
|
|
|> assign(:show_picker, false)
|
|
|
|
|
|> assign(:picker_filter, "")
|
2026-02-26 21:47:24 +00:00
|
|
|
|> assign(:expanded, MapSet.new())
|
2026-02-27 08:06:17 +00:00
|
|
|
|> assign(:live_region_message, nil)
|
|
|
|
|
|> assign(:show_preview, false)
|
|
|
|
|
|> assign(:preview_data, preview_data)
|
|
|
|
|
|> assign(:logo_image, Media.get_logo())
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:header_image, Media.get_header())
|
|
|
|
|
|> assign(:image_picker_block_id, nil)
|
|
|
|
|
|> assign(:image_picker_field_key, nil)
|
|
|
|
|
|> assign(:image_picker_images, [])
|
2026-02-28 09:43:03 +00:00
|
|
|
|> assign(:image_picker_search, "")
|
2026-02-28 17:55:02 +00:00
|
|
|
|> assign(:is_custom_page, !Page.system_slug?(slug))
|
|
|
|
|
|> assign(:is_legal_page, LegalPages.legal_slug?(slug))
|
2026-02-28 20:17:03 +00:00
|
|
|
|> assign_settings_form(slug)
|
2026-02-28 17:55:02 +00:00
|
|
|
|> 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
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Block manipulation events ────────────────────────────────────
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
@impl true
|
|
|
|
|
def handle_event("move_up", %{"id" => block_id}, socket) do
|
2026-02-27 16:22:35 +00:00
|
|
|
case BlockEditor.move_up(socket.assigns.blocks, block_id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:noreply, apply_mutation(socket, new_blocks, message)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event("move_down", %{"id" => block_id}, socket) do
|
2026-02-27 16:22:35 +00:00
|
|
|
case BlockEditor.move_down(socket.assigns.blocks, block_id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:noreply, apply_mutation(socket, new_blocks, message)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event("remove_block", %{"id" => block_id}, socket) do
|
2026-02-27 16:22:35 +00:00
|
|
|
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
|
|
|
|
|
{:noreply, apply_mutation(socket, new_blocks, message)}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
|
2026-02-27 16:22:35 +00:00
|
|
|
case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:noreply, apply_mutation(socket, new_blocks, message)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
def handle_event("add_block", %{"type" => type}, socket) do
|
|
|
|
|
case BlockEditor.add_block(socket.assigns.blocks, type) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:noreply,
|
|
|
|
|
socket
|
2026-02-28 12:16:15 +00:00
|
|
|
|> apply_mutation(new_blocks, message)
|
|
|
|
|
|> assign(:show_picker, false)}
|
2026-02-26 21:47:24 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
|
|
|
|
end
|
2026-02-26 21:47:24 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event("update_block_settings", params, socket) do
|
|
|
|
|
block_id = params["block_id"]
|
|
|
|
|
new_settings = params["block_settings"] || %{}
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
|
|
|
|
|
{:ok, new_blocks} ->
|
2026-02-28 17:55:02 +00:00
|
|
|
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}
|
2026-02-26 21:47:24 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-26 21:47:24 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Repeater events ──────────────────────────────────────────────
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
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)}
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-27 00:54:13 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event(
|
|
|
|
|
"repeater_remove",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
2026-02-27 16:22:35 +00:00
|
|
|
index = String.to_integer(index_str)
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
case BlockEditor.repeater_remove(socket.assigns.blocks, block_id, field_key, index) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:noreply, apply_mutation(socket, new_blocks, message)}
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
2026-02-27 00:54:13 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_event(
|
|
|
|
|
"repeater_move",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
2026-02-27 16:22:35 +00:00
|
|
|
index = String.to_integer(index_str)
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
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)}
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-28 01:00:48 +00:00
|
|
|
# ── 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
|
2026-02-28 12:16:15 +00:00
|
|
|
|> apply_mutation(new_blocks, "Image selected")
|
2026-02-28 01:00:48 +00:00
|
|
|
|> 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
|
2026-02-28 17:55:02 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-02-28 01:00:48 +00:00
|
|
|
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
|
|
|
|
|
{:ok, new_blocks} ->
|
2026-02-28 17:55:02 +00:00
|
|
|
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}
|
2026-02-28 01:00:48 +00:00
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:noreply, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── UI events ────────────────────────────────────────────────────
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
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)
|
2026-02-27 00:54:13 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
{new_expanded, action} =
|
|
|
|
|
if MapSet.member?(expanded, block_id) do
|
|
|
|
|
{MapSet.delete(expanded, block_id), "collapsed"}
|
2026-02-27 00:54:13 +00:00
|
|
|
else
|
2026-02-27 16:22:35 +00:00
|
|
|
{MapSet.put(expanded, block_id), "expanded"}
|
2026-02-27 00:54:13 +00:00
|
|
|
end
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:expanded, new_expanded)
|
|
|
|
|
|> assign(:live_region_message, "#{block_name} settings #{action}")}
|
2026-02-27 00:54:13 +00:00
|
|
|
end
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
def handle_event("toggle_preview", _params, socket) do
|
|
|
|
|
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Page actions ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
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} ->
|
2026-03-07 21:01:54 +00:00
|
|
|
Process.send_after(self(), :clear_save_status, 3000)
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:dirty, false)
|
2026-03-07 21:01:54 +00:00
|
|
|
|> assign(:save_status, :saved)}
|
2026-02-26 21:15:01 +00:00
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
2026-03-07 21:01:54 +00:00
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:save_status, :error)
|
|
|
|
|
|> put_flash(:error, "Failed to save page")}
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-28 17:55:02 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
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)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:history, [])
|
|
|
|
|
|> assign(:future, [])
|
2026-02-26 21:15:01 +00:00
|
|
|
|> assign(:dirty, false)
|
|
|
|
|
|> put_flash(:info, "Page reset to defaults")}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-28 12:16:15 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-28 20:17:03 +00:00
|
|
|
# ── 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
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
else
|
|
|
|
|
page_struct = Pages.get_page_struct(slug)
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:show_settings, false)
|
|
|
|
|
|> assign(:page_struct, page_struct)
|
|
|
|
|
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-07 21:01:54 +00:00
|
|
|
# ── Handle info ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@impl true
|
|
|
|
|
def handle_info(:clear_save_status, socket) do
|
|
|
|
|
{:noreply, assign(socket, :save_status, :idle)}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Render ───────────────────────────────────────────────────────
|
2026-02-27 08:06:17 +00:00
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
@impl true
|
|
|
|
|
def render(assigns) do
|
|
|
|
|
~H"""
|
2026-02-28 12:16:15 +00:00
|
|
|
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
<.link navigate={~p"/admin/pages"} class="admin-back-link">
|
2026-02-26 21:15:01 +00:00
|
|
|
← Pages
|
|
|
|
|
</.link>
|
|
|
|
|
<.header>
|
|
|
|
|
{@page_data.title}
|
|
|
|
|
<:actions>
|
2026-02-27 08:06:17 +00:00
|
|
|
<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>
|
2026-02-28 20:17:03 +00:00
|
|
|
<button
|
2026-02-28 09:43:03 +00:00
|
|
|
:if={@is_custom_page}
|
2026-02-28 20:17:03 +00:00
|
|
|
phx-click="toggle_settings"
|
|
|
|
|
class={[
|
|
|
|
|
"admin-btn admin-btn-sm admin-btn-ghost",
|
|
|
|
|
@show_settings && "admin-btn-active"
|
|
|
|
|
]}
|
2026-02-28 09:43:03 +00:00
|
|
|
>
|
|
|
|
|
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
2026-02-28 20:17:03 +00:00
|
|
|
</button>
|
2026-02-28 12:16:15 +00:00
|
|
|
<button
|
|
|
|
|
phx-click="undo"
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
2026-02-28 12:16:15 +00:00
|
|
|
disabled={@history == []}
|
|
|
|
|
aria-label="Undo"
|
|
|
|
|
>
|
|
|
|
|
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
phx-click="redo"
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
2026-02-28 12:16:15 +00:00
|
|
|
disabled={@future == []}
|
|
|
|
|
aria-label="Redo"
|
|
|
|
|
>
|
|
|
|
|
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
|
|
|
|
</button>
|
2026-02-28 17:55:02 +00:00
|
|
|
<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>
|
2026-02-26 21:15:01 +00:00
|
|
|
<button
|
2026-02-28 09:43:03 +00:00
|
|
|
:if={!@is_custom_page}
|
2026-02-26 21:15:01 +00:00
|
|
|
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"
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
class="admin-btn admin-btn-sm admin-btn-primary"
|
2026-02-26 21:15:01 +00:00
|
|
|
disabled={!@dirty}
|
|
|
|
|
>
|
|
|
|
|
Save
|
|
|
|
|
</button>
|
2026-03-07 21:01:54 +00:00
|
|
|
<.inline_feedback status={@save_status} />
|
2026-02-26 21:15:01 +00:00
|
|
|
</: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>
|
|
|
|
|
|
2026-02-28 17:55:02 +00:00
|
|
|
<%!-- Status badges --%>
|
complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
chart dimensions) and one JS.toggle target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00
|
|
|
<div class="admin-editor-badges">
|
2026-02-28 17:55:02 +00:00
|
|
|
<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>
|
2026-02-26 21:15:01 +00:00
|
|
|
|
2026-02-28 20:17:03 +00:00
|
|
|
<%!-- 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
|
|
|
|
|
/>
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-02-27 08:06:17 +00:00
|
|
|
<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>
|
2026-02-26 21:15:01 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-27 08:06:17 +00:00
|
|
|
<%!-- 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}
|
2026-03-03 14:52:31 +00:00
|
|
|
site_name={@site_name}
|
2026-02-27 08:06:17 +00:00
|
|
|
generated_css={@generated_css}
|
|
|
|
|
logo_image={@logo_image}
|
|
|
|
|
header_image={@header_image}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-26 21:15:01 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<%!-- Block picker modal --%>
|
|
|
|
|
<.block_picker
|
|
|
|
|
:if={@show_picker}
|
|
|
|
|
allowed_blocks={@allowed_blocks}
|
|
|
|
|
filter={@picker_filter}
|
|
|
|
|
/>
|
2026-02-28 01:00:48 +00:00
|
|
|
|
|
|
|
|
<%!-- Image picker modal --%>
|
|
|
|
|
<.image_picker
|
|
|
|
|
:if={@image_picker_block_id}
|
|
|
|
|
images={@image_picker_images}
|
|
|
|
|
search={@image_picker_search}
|
2026-02-28 17:55:02 +00:00
|
|
|
upload={@uploads.image_picker_upload}
|
2026-02-28 01:00:48 +00:00
|
|
|
/>
|
2026-02-26 21:15:01 +00:00
|
|
|
</div>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 08:06:17 +00:00
|
|
|
# ── 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)
|
2026-02-28 11:18:37 +00:00
|
|
|
|> assign(:header_nav_items, BerrypodWeb.ThemeHook.default_header_nav())
|
|
|
|
|
|> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav())
|
2026-02-27 08:06:17 +00:00
|
|
|
|> 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
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Helpers ──────────────────────────────────────────────────────
|
2026-02-26 21:47:24 +00:00
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
defp apply_mutation(socket, new_blocks, message) do
|
2026-02-28 12:16:15 +00:00
|
|
|
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
socket
|
|
|
|
|
|> assign(:blocks, new_blocks)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:history, history)
|
|
|
|
|
|> assign(:future, [])
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:dirty, true)
|
|
|
|
|
|> assign(:live_region_message, message)
|
2026-02-26 21:47:24 +00:00
|
|
|
end
|
2026-02-26 21:15:01 +00:00
|
|
|
end
|