diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index ff7954f..d4e1f2b 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -183,6 +183,11 @@
font-size: 0.75rem;
}
+.admin-btn-xs {
+ padding: 0.125rem 0.375rem;
+ font-size: 0.6875rem;
+}
+
.admin-btn-icon {
padding: 0.5rem;
aspect-ratio: 1;
@@ -469,6 +474,11 @@
font-size: 0.625rem;
}
+.admin-badge-warning {
+ background-color: color-mix(in oklch, var(--t-status-warning, #f59e0b) 15%, transparent);
+ color: var(--t-status-warning, #b45309);
+}
+
/* ── Dropdown ── */
.admin-dropdown {
@@ -1465,4 +1475,95 @@
border-style: dashed;
}
+/* ── Live editor layout (sidebar on shop pages) ── */
+
+.page-editor-live {
+ display: flex;
+ min-height: 100vh;
+}
+
+.page-editor-sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 360px;
+ background: var(--t-surface-base);
+ border-right: 1px solid var(--t-border-default);
+ overflow-y: auto;
+ z-index: 40;
+ box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
+ padding: 1rem;
+ transition: transform 0.25s ease;
+ transform: translateX(0);
+}
+
+/* Hidden sidebar — slides off-screen */
+[data-sidebar-open="false"] .page-editor-sidebar {
+ transform: translateX(-100%);
+ box-shadow: none;
+}
+
+.page-editor-sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.page-editor-sidebar-title {
+ font-size: 1rem;
+ font-weight: 600;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.page-editor-sidebar-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.page-editor-sidebar-dirty {
+ margin-bottom: 0.5rem;
+}
+
+.page-editor-content {
+ flex: 1;
+ margin-left: 360px;
+ min-width: 0;
+ transition: margin-left 0.25s ease;
+}
+
+/* Content goes full-width when sidebar is hidden */
+[data-sidebar-open="false"] .page-editor-content {
+ margin-left: 0;
+}
+
+/* Clickable backdrop to dismiss the sidebar */
+.page-editor-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 39;
+ background: rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+}
+
+/* Mobile: sidebar overlays content, no margin push */
+@media (max-width: 63.99em) {
+ .page-editor-sidebar {
+ width: 85%;
+ max-width: 360px;
+ }
+
+ .page-editor-content {
+ margin-left: 0;
+ }
+}
+
} /* @layer admin */
diff --git a/lib/berrypod/pages/block_editor.ex b/lib/berrypod/pages/block_editor.ex
new file mode 100644
index 0000000..7d71b5a
--- /dev/null
+++ b/lib/berrypod/pages/block_editor.ex
@@ -0,0 +1,255 @@
+defmodule Berrypod.Pages.BlockEditor do
+ @moduledoc """
+ Pure functions for block list manipulation.
+
+ Used by both the admin page editor and the live page editor sidebar.
+ No side effects — takes a block list in, returns a block list out.
+ """
+
+ alias Berrypod.Pages.{BlockTypes, Defaults}
+
+ # ── Block operations ─────────────────────────────────────────────
+
+ def move_up(blocks, block_id) do
+ idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+
+ if idx && idx > 0 do
+ block = Enum.at(blocks, idx)
+ name = block_display_name(block)
+
+ new_blocks =
+ blocks
+ |> List.delete_at(idx)
+ |> List.insert_at(idx - 1, block)
+
+ {:ok, new_blocks, "#{name} moved to position #{idx}"}
+ else
+ :noop
+ end
+ end
+
+ def move_down(blocks, block_id) do
+ idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+
+ if idx && idx < length(blocks) - 1 do
+ block = Enum.at(blocks, idx)
+ name = block_display_name(block)
+
+ new_blocks =
+ blocks
+ |> List.delete_at(idx)
+ |> List.insert_at(idx + 1, block)
+
+ {:ok, new_blocks, "#{name} moved to position #{idx + 2}"}
+ else
+ :noop
+ end
+ end
+
+ def remove_block(blocks, block_id) do
+ block = Enum.find(blocks, &(&1["id"] == block_id))
+ name = block_display_name(block)
+ new_blocks = Enum.reject(blocks, &(&1["id"] == block_id))
+ {:ok, new_blocks, "#{name} removed"}
+ end
+
+ def duplicate_block(blocks, block_id) do
+ idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+
+ if idx do
+ original = Enum.at(blocks, idx)
+
+ copy = %{
+ "id" => Defaults.generate_block_id(),
+ "type" => original["type"],
+ "settings" => original["settings"] || %{}
+ }
+
+ name = block_display_name(original)
+ new_blocks = List.insert_at(blocks, idx + 1, copy)
+ {:ok, new_blocks, "#{name} duplicated"}
+ else
+ :noop
+ end
+ end
+
+ def add_block(blocks, type) do
+ block_def = BlockTypes.get(type)
+
+ if block_def do
+ default_settings =
+ block_def
+ |> Map.get(:settings_schema, [])
+ |> Enum.into(%{}, fn field -> {field.key, field.default} end)
+
+ new_block = %{
+ "id" => Defaults.generate_block_id(),
+ "type" => type,
+ "settings" => default_settings
+ }
+
+ {:ok, blocks ++ [new_block], "#{block_def.name} added"}
+ else
+ :noop
+ end
+ end
+
+ def update_settings(blocks, block_id, new_settings) do
+ block = Enum.find(blocks, &(&1["id"] == block_id))
+
+ if block do
+ schema =
+ case BlockTypes.get(block["type"]) do
+ %{settings_schema: s} -> s
+ _ -> []
+ end
+
+ coerced = coerce_settings(new_settings, schema)
+
+ new_blocks =
+ Enum.map(blocks, fn b ->
+ if b["id"] == block_id do
+ Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced))
+ else
+ b
+ end
+ end)
+
+ {:ok, new_blocks}
+ else
+ :noop
+ end
+ end
+
+ # ── Repeater operations ──────────────────────────────────────────
+
+ def repeater_add(blocks, block_id, field_key) do
+ block = Enum.find(blocks, &(&1["id"] == block_id))
+
+ if block do
+ schema = BlockTypes.get(block["type"])
+ field = Enum.find(schema.settings_schema, &(&1.key == field_key))
+ empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end)
+
+ items = (block["settings"] || %{})[field_key] || []
+ new_items = items ++ [empty_item]
+ new_blocks = update_block_field(blocks, block_id, field_key, new_items)
+ {:ok, new_blocks, "Item added"}
+ else
+ :noop
+ end
+ end
+
+ def repeater_remove(blocks, block_id, field_key, index) do
+ block = Enum.find(blocks, &(&1["id"] == block_id))
+
+ if block do
+ items = (block["settings"] || %{})[field_key] || []
+ new_items = List.delete_at(items, index)
+ new_blocks = update_block_field(blocks, block_id, field_key, new_items)
+ {:ok, new_blocks, "Item removed"}
+ else
+ :noop
+ end
+ end
+
+ def repeater_move(blocks, block_id, field_key, index, direction) do
+ block = Enum.find(blocks, &(&1["id"] == block_id))
+
+ if block do
+ items = (block["settings"] || %{})[field_key] || []
+ target = if direction == "up", do: index - 1, else: index + 1
+
+ if target >= 0 and target < length(items) do
+ item = Enum.at(items, index)
+
+ new_items =
+ items
+ |> List.delete_at(index)
+ |> List.insert_at(target, item)
+
+ new_blocks = update_block_field(blocks, block_id, field_key, new_items)
+ {:ok, new_blocks, "Item moved #{direction}"}
+ else
+ :noop
+ end
+ else
+ :noop
+ end
+ end
+
+ # ── Helpers ──────────────────────────────────────────────────────
+
+ def block_display_name(nil), do: "Block"
+
+ def block_display_name(block) do
+ case BlockTypes.get(block["type"]) do
+ %{name: name} -> name
+ _ -> block["type"]
+ end
+ end
+
+ def has_settings?(block) do
+ case BlockTypes.get(block["type"]) do
+ %{settings_schema: [_ | _]} -> true
+ _ -> false
+ end
+ end
+
+ def settings_with_defaults(block) do
+ schema =
+ case BlockTypes.get(block["type"]) do
+ %{settings_schema: s} -> s
+ _ -> []
+ end
+
+ defaults = Map.new(schema, fn field -> {field.key, field.default} end)
+ Map.merge(defaults, block["settings"] || %{})
+ end
+
+ def coerce_settings(params, schema) do
+ type_map = Map.new(schema, fn field -> {field.key, field} end)
+
+ Map.new(params, fn {key, value} ->
+ case type_map[key] do
+ %{type: :number, default: default} ->
+ {key, parse_number(value, default)}
+
+ %{type: :repeater} ->
+ {key, indexed_map_to_list(value)}
+
+ _ ->
+ {key, value}
+ end
+ end)
+ end
+
+ def parse_number(value, default) when is_binary(value) do
+ case Integer.parse(value) do
+ {n, ""} -> n
+ _ -> default
+ end
+ end
+
+ def parse_number(value, _default) when is_integer(value), do: value
+ def parse_number(_value, default), do: default
+
+ defp update_block_field(blocks, block_id, field_key, new_value) do
+ Enum.map(blocks, fn b ->
+ if b["id"] == block_id do
+ settings = Map.put(b["settings"] || %{}, field_key, new_value)
+ Map.put(b, "settings", settings)
+ else
+ b
+ end
+ end)
+ end
+
+ defp indexed_map_to_list(map) when is_map(map) do
+ map
+ |> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end)
+ |> Enum.map(fn {_k, v} -> v end)
+ end
+
+ defp indexed_map_to_list(value), do: value
+end
diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex
index c87552a..1f6219c 100644
--- a/lib/berrypod/pages/block_types.ex
+++ b/lib/berrypod/pages/block_types.ex
@@ -233,8 +233,9 @@ defmodule Berrypod.Pages.BlockTypes do
"content_body" => %{
name: "Page content",
icon: "hero-document-text",
- allowed_on: ["about", "delivery", "privacy", "terms"],
+ allowed_on: :all,
settings_schema: [
+ %SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
%SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
%SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""}
]
diff --git a/lib/berrypod_web/components/block_editor_components.ex b/lib/berrypod_web/components/block_editor_components.ex
new file mode 100644
index 0000000..beda210
--- /dev/null
+++ b/lib/berrypod_web/components/block_editor_components.ex
@@ -0,0 +1,405 @@
+defmodule BerrypodWeb.BlockEditorComponents do
+ @moduledoc """
+ Shared UI components for the block editor.
+
+ Used by both the admin page editor (`Admin.Pages.Editor`) and the
+ live page editor sidebar (`PageEditorHook` + `PageRenderer`).
+
+ All components accept an `event_prefix` attr to namespace phx-click
+ events. The admin editor uses `""` (default), the live sidebar uses
+ `"editor_"`.
+ """
+
+ use Phoenix.Component
+
+ import BerrypodWeb.CoreComponents, only: [icon: 1]
+
+ alias Berrypod.Pages.{BlockEditor, BlockTypes}
+
+ # ── Block card ─────────────────────────────────────────────────
+
+ attr :block, :map, required: true
+ attr :idx, :integer, required: true
+ attr :total, :integer, required: true
+ attr :expanded, :any, required: true
+ attr :event_prefix, :string, default: ""
+
+ def block_card(assigns) do
+ block_type = BlockTypes.get(assigns.block["type"])
+ has_settings = BlockEditor.has_settings?(assigns.block)
+ expanded = MapSet.member?(assigns.expanded, assigns.block["id"])
+
+ assigns =
+ assigns
+ |> assign(:block_type, block_type)
+ |> assign(:has_settings, has_settings)
+ |> assign(:is_expanded, expanded)
+
+ ~H"""
+
+
+
+ <.block_settings_form
+ :if={@is_expanded}
+ block={@block}
+ schema={@block_type.settings_schema}
+ event_prefix={@event_prefix}
+ />
+
+ """
+ end
+
+ # ── Settings form ──────────────────────────────────────────────
+
+ attr :block, :map, required: true
+ attr :schema, :list, required: true
+ attr :event_prefix, :string, default: ""
+
+ def block_settings_form(assigns) do
+ settings = BlockEditor.settings_with_defaults(assigns.block)
+ assigns = assign(assigns, :settings, settings)
+
+ ~H"""
+
+ <%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
+ <.link
+ :if={@is_admin && !@editing && @editor_current_path}
+ patch={"#{@editor_current_path}?edit=true"}
+ class="header-icon-btn"
+ aria-label="Edit page"
+ >
+ <.edit_pencil_svg />
+
+
<.link
:if={@is_admin}
href="/admin"
@@ -933,6 +959,25 @@ defmodule BerrypodWeb.ShopComponents.Layout do
"""
end
+ defp edit_pencil_svg(assigns) do
+ ~H"""
+
+ """
+ end
+
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex
index c4153f4..bf81a67 100644
--- a/lib/berrypod_web/live/admin/pages/editor.ex
+++ b/lib/berrypod_web/live/admin/pages/editor.ex
@@ -2,10 +2,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
- alias Berrypod.Pages.{BlockTypes, Defaults}
+ alias Berrypod.Pages.{BlockEditor, BlockTypes}
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)
@@ -35,97 +37,125 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:header_image, Media.get_header())}
end
+ # ── Block manipulation events ────────────────────────────────────
+
@impl true
def handle_event("move_up", %{"id" => block_id}, socket) do
- blocks = socket.assigns.blocks
- idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+ case BlockEditor.move_up(socket.assigns.blocks, block_id) do
+ {:ok, new_blocks, message} ->
+ {:noreply, apply_mutation(socket, new_blocks, message)}
- if idx && idx > 0 do
- block = Enum.at(blocks, idx)
- block_name = block_display_name(block)
- new_pos = idx
-
- new_blocks =
- blocks
- |> List.delete_at(idx)
- |> List.insert_at(idx - 1, block)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
- else
- {:noreply, socket}
+ :noop ->
+ {:noreply, socket}
end
end
def handle_event("move_down", %{"id" => block_id}, socket) do
- blocks = socket.assigns.blocks
- idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+ case BlockEditor.move_down(socket.assigns.blocks, block_id) do
+ {:ok, new_blocks, message} ->
+ {:noreply, apply_mutation(socket, new_blocks, message)}
- if idx && idx < length(blocks) - 1 do
- block = Enum.at(blocks, idx)
- block_name = block_display_name(block)
- new_pos = idx + 2
-
- new_blocks =
- blocks
- |> List.delete_at(idx)
- |> List.insert_at(idx + 1, block)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
- else
- {:noreply, socket}
+ :noop ->
+ {:noreply, socket}
end
end
def handle_event("remove_block", %{"id" => block_id}, socket) do
- block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
- block_name = block_display_name(block)
- new_blocks = Enum.reject(socket.assigns.blocks, &(&1["id"] == block_id))
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "#{block_name} removed")}
+ {: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
- blocks = socket.assigns.blocks
- idx = Enum.find_index(blocks, &(&1["id"] == block_id))
+ case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
+ {:ok, new_blocks, message} ->
+ {:noreply, apply_mutation(socket, new_blocks, message)}
- if idx do
- original = Enum.at(blocks, idx)
-
- copy = %{
- "id" => Defaults.generate_block_id(),
- "type" => original["type"],
- "settings" => original["settings"] || %{}
- }
-
- block_name = block_display_name(original)
- new_blocks = List.insert_at(blocks, idx + 1, copy)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "#{block_name} duplicated")}
- else
- {:noreply, socket}
+ :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
+ |> assign(:blocks, new_blocks)
+ |> assign(:dirty, true)
+ |> assign(:show_picker, false)
+ |> assign(:live_region_message, message)}
+
+ :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} ->
+ {:noreply,
+ socket
+ |> assign(:blocks, new_blocks)
+ |> assign(:dirty, true)}
+
+ :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
+
+ # ── 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 = block_display_name(block)
+ block_name = BlockEditor.block_display_name(block)
{new_expanded, action} =
if MapSet.member?(expanded, block_id) do
@@ -140,122 +170,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:live_region_message, "#{block_name} settings #{action}")}
end
- def handle_event("update_block_settings", params, socket) do
- block_id = params["block_id"]
- new_settings = params["block_settings"] || %{}
-
- # Find the block and its schema to coerce types
- block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
-
- if block do
- schema =
- case BlockTypes.get(block["type"]) do
- %{settings_schema: s} -> s
- _ -> []
- end
-
- coerced = coerce_settings(new_settings, schema)
-
- new_blocks =
- Enum.map(socket.assigns.blocks, fn b ->
- if b["id"] == block_id do
- Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced))
- else
- b
- end
- end)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)}
- else
- {:noreply, socket}
- end
- end
-
- def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
- block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
-
- if block do
- schema = BlockTypes.get(block["type"])
- field = Enum.find(schema.settings_schema, &(&1.key == field_key))
- empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end)
-
- items = (block["settings"] || %{})[field_key] || []
- new_items = items ++ [empty_item]
-
- new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "Item added")}
- else
- {:noreply, socket}
- end
- end
-
- def handle_event(
- "repeater_remove",
- %{"block-id" => block_id, "field" => field_key, "index" => index_str},
- socket
- ) do
- block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
-
- if block do
- index = String.to_integer(index_str)
- items = (block["settings"] || %{})[field_key] || []
- new_items = List.delete_at(items, index)
-
- new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "Item removed")}
- else
- {:noreply, socket}
- end
- end
-
- def handle_event(
- "repeater_move",
- %{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
- socket
- ) do
- block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
-
- if block do
- index = String.to_integer(index_str)
- items = (block["settings"] || %{})[field_key] || []
- target = if dir == "up", do: index - 1, else: index + 1
-
- if target >= 0 and target < length(items) do
- item = Enum.at(items, index)
-
- new_items =
- items
- |> List.delete_at(index)
- |> List.insert_at(target, item)
-
- new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
-
- {:noreply,
- socket
- |> assign(:blocks, new_blocks)
- |> assign(:dirty, true)
- |> assign(:live_region_message, "Item moved #{dir}")}
- else
- {:noreply, socket}
- end
- else
- {:noreply, socket}
- end
- end
-
def handle_event("show_picker", _params, socket) do
{:noreply,
socket
@@ -271,33 +185,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:noreply, assign(socket, :picker_filter, value)}
end
- def handle_event("add_block", %{"type" => type}, socket) do
- block_def = BlockTypes.get(type)
-
- if block_def do
- # Build default settings from schema
- default_settings =
- block_def
- |> Map.get(:settings_schema, [])
- |> Enum.into(%{}, fn field -> {field.key, field.default} end)
-
- new_block = %{
- "id" => Defaults.generate_block_id(),
- "type" => type,
- "settings" => default_settings
- }
-
- {:noreply,
- socket
- |> assign(:blocks, socket.assigns.blocks ++ [new_block])
- |> assign(:dirty, true)
- |> assign(:show_picker, false)
- |> assign(:live_region_message, "#{block_def.name} added")}
- else
- {:noreply, socket}
- 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
@@ -325,9 +218,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> put_flash(:info, "Page reset to defaults")}
end
- def handle_event("toggle_preview", _params, socket) do
- {:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
- end
+ # ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
@@ -439,7 +330,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
# ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do
- # Build a temporary page struct from working state
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
preview =
@@ -554,427 +444,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> Enum.reject(&is_nil/1)
end
- # ── Block card ─────────────────────────────────────────────────────
+ # ── Helpers ──────────────────────────────────────────────────────
- defp block_card(assigns) do
- block_type = BlockTypes.get(assigns.block["type"])
- has_settings = has_settings?(assigns.block)
- expanded = MapSet.member?(assigns.expanded, assigns.block["id"])
-
- assigns =
- assigns
- |> assign(:block_type, block_type)
- |> assign(:has_settings, has_settings)
- |> assign(:is_expanded, expanded)
-
- ~H"""
-
-
-
- <.block_settings_form
- :if={@is_expanded}
- block={@block}
- schema={@block_type.settings_schema}
- />
-
- """
- end
-
- defp block_settings_form(assigns) do
- settings = settings_with_defaults(assigns.block)
- assigns = assign(assigns, :settings, settings)
-
- ~H"""
-
- """
- end
-
- defp block_field(%{field: %{type: :select}} = assigns) do
- ~H"""
-
-
-
- """
- end
-
- defp block_field(%{field: %{type: :textarea}} = assigns) do
- ~H"""
-
-
-
- """
- end
-
- defp block_field(%{field: %{type: :number}} = assigns) do
- ~H"""
-
-
-
- """
- end
-
- defp block_field(%{field: %{type: :repeater}} = assigns) do
- items = if is_list(assigns.value), do: assigns.value, else: []
- item_count = length(items)
-
- assigns =
- assigns
- |> assign(:items, items)
- |> assign(:item_count, item_count)
-
- ~H"""
-
-
{@field.label}
-
-
- -
-
-
-
-
-
-
- """
- end
-
- defp repeater_item_field(assigns) do
- value = assigns.item[assigns.sub_field.key] || ""
-
- assigns = assign(assigns, :value, value)
-
- ~H"""
-
- """
- end
-
- defp block_field(assigns) do
- ~H"""
-
-
-
- """
- end
-
- defp block_picker(assigns) do
- filter = String.downcase(assigns.filter)
-
- filtered =
- assigns.allowed_blocks
- |> Enum.filter(fn {_type, def} ->
- filter == "" or String.contains?(String.downcase(def.name), filter)
- end)
- |> Enum.sort_by(fn {_type, def} -> def.name end)
-
- assigns = assign(assigns, :filtered_blocks, filtered)
-
- ~H"""
-
-
-
-
-
-
-
-
-
-
- No matching blocks.
-
-
-
-
- """
- end
-
- defp block_display_name(nil), do: "Block"
-
- defp block_display_name(block) do
- case BlockTypes.get(block["type"]) do
- %{name: name} -> name
- _ -> block["type"]
- end
- end
-
- defp coerce_settings(params, schema) do
- type_map = Map.new(schema, fn field -> {field.key, field} end)
-
- Map.new(params, fn {key, value} ->
- case type_map[key] do
- %{type: :number, default: default} ->
- {key, parse_number(value, default)}
-
- %{type: :repeater} ->
- {key, indexed_map_to_list(value)}
-
- _ ->
- {key, value}
- end
- end)
- end
-
- defp indexed_map_to_list(map) when is_map(map) do
- map
- |> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end)
- |> Enum.map(fn {_k, v} -> v end)
- end
-
- defp indexed_map_to_list(value), do: value
-
- defp update_block_field(blocks, block_id, field_key, new_value) do
- Enum.map(blocks, fn b ->
- if b["id"] == block_id do
- settings = Map.put(b["settings"] || %{}, field_key, new_value)
- Map.put(b, "settings", settings)
- else
- b
- end
- end)
- end
-
- defp parse_number(value, default) when is_binary(value) do
- case Integer.parse(value) do
- {n, ""} -> n
- _ -> default
- end
- end
-
- defp parse_number(value, _default) when is_integer(value), do: value
- defp parse_number(_value, default), do: default
-
- defp has_settings?(block) do
- case BlockTypes.get(block["type"]) do
- %{settings_schema: [_ | _]} -> true
- _ -> false
- end
- end
-
- defp settings_with_defaults(block) do
- schema =
- case BlockTypes.get(block["type"]) do
- %{settings_schema: s} -> s
- _ -> []
- end
-
- defaults = Map.new(schema, fn field -> {field.key, field.default} end)
- Map.merge(defaults, block["settings"] || %{})
+ defp apply_mutation(socket, new_blocks, message) do
+ socket
+ |> assign(:blocks, new_blocks)
+ |> assign(:dirty, true)
+ |> assign(:live_region_message, message)
end
end
diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex
new file mode 100644
index 0000000..5f3196b
--- /dev/null
+++ b/lib/berrypod_web/page_editor_hook.ex
@@ -0,0 +1,360 @@
+defmodule BerrypodWeb.PageEditorHook do
+ @moduledoc """
+ LiveView on_mount hook for the live page editor sidebar.
+
+ Mounted in the public_shop live_session. When an admin visits any shop
+ page with `?edit=true` in the URL, this hook activates editing mode:
+ loads a working copy of the page's blocks, attaches event handlers for
+ block manipulation, and sets assigns that trigger the editor sidebar
+ in `PageRenderer.render_page/1`.
+
+ Non-admin users are unaffected — the hook just assigns `editing: false`.
+
+ ## Actions
+
+ - `:mount_page_editor` — sets up editing assigns and attaches hooks
+ """
+
+ import Phoenix.Component, only: [assign: 3]
+ import Phoenix.LiveView, only: [attach_hook: 4, put_flash: 3, push_navigate: 2]
+
+ alias Berrypod.Pages
+ alias Berrypod.Pages.{BlockEditor, BlockTypes}
+
+ def on_mount(:mount_page_editor, _params, _session, socket) do
+ socket =
+ socket
+ |> assign(:editing, false)
+ |> assign(:editing_blocks, nil)
+ |> assign(:editor_dirty, false)
+ |> assign(:editor_expanded, MapSet.new())
+ |> assign(:editor_show_picker, false)
+ |> assign(:editor_picker_filter, "")
+ |> assign(:editor_allowed_blocks, nil)
+ |> assign(:editor_live_region_message, nil)
+ |> assign(:editor_current_path, nil)
+ |> assign(:editor_sidebar_open, true)
+ |> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
+ |> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
+ |> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
+
+ {:cont, socket}
+ end
+
+ # ── handle_params: detect ?edit=true ─────────────────────────────
+
+ defp handle_editor_params(_params, uri, socket) do
+ parsed = URI.parse(uri)
+ query = URI.decode_query(parsed.query || "")
+ wants_edit = query["edit"] == "true"
+
+ # Always store the current path for the edit button and "done" navigation
+ socket = assign(socket, :editor_current_path, parsed.path)
+
+ cond do
+ wants_edit and socket.assigns.is_admin and socket.assigns[:page] ->
+ # Page already loaded — enter edit mode and halt (no need for module handle_params)
+ {:halt, enter_edit_mode(socket)}
+
+ wants_edit and socket.assigns.is_admin ->
+ # Page not loaded yet (e.g. Shop.Content loads in handle_params),
+ # defer initialisation until after the LiveView sets @page
+ send(self(), :editor_deferred_init)
+ {:cont, assign(socket, :editing, true)}
+
+ socket.assigns.editing and not wants_edit ->
+ # Exiting edit mode — halt since we've handled the transition
+ {:halt, exit_edit_mode(socket)}
+
+ true ->
+ {:cont, socket}
+ end
+ end
+
+ # ── handle_info: deferred init ───────────────────────────────────
+
+ defp handle_editor_info(:editor_deferred_init, socket) do
+ if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
+ {:halt, enter_edit_mode(socket)}
+ else
+ {:cont, socket}
+ end
+ end
+
+ defp handle_editor_info(_msg, socket), do: {:cont, socket}
+
+ # ── handle_event: editor_* events ────────────────────────────────
+
+ defp handle_editor_event("editor_" <> action, params, socket) do
+ if socket.assigns.editing do
+ handle_editor_action(action, params, socket)
+ else
+ {:cont, socket}
+ end
+ end
+
+ defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
+
+ # ── Block manipulation actions ───────────────────────────────────
+
+ defp handle_editor_action("move_up", %{"id" => id}, socket) do
+ case BlockEditor.move_up(socket.assigns.editing_blocks, id) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :reorder)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action("move_down", %{"id" => id}, socket) do
+ case BlockEditor.move_down(socket.assigns.editing_blocks, id) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :reorder)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action("remove_block", %{"id" => id}, socket) do
+ {:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.editing_blocks, id)
+ {:halt, apply_mutation(socket, new_blocks, message, :content)}
+ end
+
+ defp handle_editor_action("duplicate_block", %{"id" => id}, socket) do
+ case BlockEditor.duplicate_block(socket.assigns.editing_blocks, id) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :content)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action("add_block", %{"type" => type}, socket) do
+ case BlockEditor.add_block(socket.assigns.editing_blocks, type) do
+ {:ok, new_blocks, message} ->
+ socket =
+ socket
+ |> assign(:editing_blocks, new_blocks)
+ |> assign(:editor_dirty, true)
+ |> assign(:editor_show_picker, false)
+ |> assign(:editor_live_region_message, message)
+ |> reload_block_data(new_blocks)
+
+ {:halt, socket}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action("update_block_settings", params, socket) do
+ block_id = params["block_id"]
+ new_settings = params["block_settings"] || %{}
+
+ case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
+ {:ok, new_blocks} ->
+ socket =
+ socket
+ |> assign(:editing_blocks, new_blocks)
+ |> assign(:editor_dirty, true)
+ |> reload_block_data(new_blocks)
+
+ {:halt, socket}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ # ── Repeater actions ─────────────────────────────────────────────
+
+ defp handle_editor_action(
+ "repeater_add",
+ %{"block-id" => block_id, "field" => field_key},
+ socket
+ ) do
+ case BlockEditor.repeater_add(socket.assigns.editing_blocks, block_id, field_key) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :content)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action(
+ "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.editing_blocks, block_id, field_key, index) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :content)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_editor_action(
+ "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.editing_blocks,
+ block_id,
+ field_key,
+ index,
+ dir
+ ) do
+ {:ok, new_blocks, message} ->
+ {:halt, apply_mutation(socket, new_blocks, message, :reorder)}
+
+ :noop ->
+ {:halt, socket}
+ end
+ end
+
+ # ── UI state actions ─────────────────────────────────────────────
+
+ defp handle_editor_action("toggle_expand", %{"id" => block_id}, socket) do
+ expanded = socket.assigns.editor_expanded
+ block = Enum.find(socket.assigns.editing_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
+
+ {:halt,
+ socket
+ |> assign(:editor_expanded, new_expanded)
+ |> assign(:editor_live_region_message, "#{block_name} settings #{action}")}
+ end
+
+ defp handle_editor_action("toggle_sidebar", _params, socket) do
+ {:halt, assign(socket, :editor_sidebar_open, !socket.assigns.editor_sidebar_open)}
+ end
+
+ defp handle_editor_action("show_picker", _params, socket) do
+ {:halt,
+ socket
+ |> assign(:editor_show_picker, true)
+ |> assign(:editor_picker_filter, "")}
+ end
+
+ defp handle_editor_action("hide_picker", _params, socket) do
+ {:halt, assign(socket, :editor_show_picker, false)}
+ end
+
+ defp handle_editor_action("filter_picker", %{"value" => value}, socket) do
+ {:halt, assign(socket, :editor_picker_filter, value)}
+ end
+
+ # ── Page actions ─────────────────────────────────────────────────
+
+ defp handle_editor_action("save", _params, socket) do
+ %{page: page, editing_blocks: blocks} = socket.assigns
+
+ case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
+ {:ok, _saved_page} ->
+ updated_page = Pages.get_page(page.slug)
+
+ socket =
+ socket
+ |> assign(:page, updated_page)
+ |> assign(:editing_blocks, updated_page.blocks)
+ |> assign(:editor_dirty, false)
+ |> put_flash(:info, "Page saved")
+
+ {:halt, socket}
+
+ {:error, _changeset} ->
+ {:halt, put_flash(socket, :error, "Failed to save page")}
+ end
+ end
+
+ defp handle_editor_action("reset_defaults", _params, socket) do
+ slug = socket.assigns.page.slug
+ :ok = Pages.reset_page(slug)
+ page = Pages.get_page(slug)
+
+ socket =
+ socket
+ |> assign(:page, page)
+ |> assign(:editing_blocks, page.blocks)
+ |> assign(:editor_dirty, false)
+ |> reload_block_data(page.blocks)
+ |> put_flash(:info, "Page reset to defaults")
+
+ {:halt, socket}
+ end
+
+ defp handle_editor_action("done", _params, socket) do
+ path = socket.assigns.editor_current_path || "/"
+ {:halt, push_navigate(socket, to: path)}
+ end
+
+ # Catch-all for unknown editor actions
+ defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
+
+ # ── Private helpers ──────────────────────────────────────────────
+
+ defp enter_edit_mode(socket) do
+ page = socket.assigns.page
+ allowed = BlockTypes.allowed_for(page.slug)
+
+ socket
+ |> assign(:editing, true)
+ |> assign(:editing_blocks, page.blocks)
+ |> assign(:editor_dirty, false)
+ |> assign(:editor_expanded, MapSet.new())
+ |> assign(:editor_show_picker, false)
+ |> assign(:editor_picker_filter, "")
+ |> assign(:editor_allowed_blocks, allowed)
+ |> assign(:editor_live_region_message, nil)
+ |> assign(:editor_sidebar_open, true)
+ end
+
+ defp exit_edit_mode(socket) do
+ socket
+ |> assign(:editing, false)
+ |> assign(:editing_blocks, nil)
+ |> assign(:editor_dirty, false)
+ |> assign(:editor_expanded, MapSet.new())
+ |> assign(:editor_show_picker, false)
+ |> assign(:editor_picker_filter, "")
+ |> assign(:editor_allowed_blocks, nil)
+ |> assign(:editor_live_region_message, nil)
+ |> assign(:editor_sidebar_open, true)
+ end
+
+ defp apply_mutation(socket, new_blocks, message, type) do
+ socket =
+ socket
+ |> assign(:editing_blocks, new_blocks)
+ |> assign(:editor_dirty, true)
+ |> assign(:editor_live_region_message, message)
+
+ case type do
+ :content -> reload_block_data(socket, new_blocks)
+ :reorder -> socket
+ end
+ end
+
+ defp reload_block_data(socket, blocks) do
+ extra = Pages.load_block_data(blocks, socket.assigns)
+ Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
+ end
+end
diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex
index d935320..03172be 100644
--- a/lib/berrypod_web/page_renderer.ex
+++ b/lib/berrypod_web/page_renderer.ex
@@ -15,6 +15,9 @@ defmodule BerrypodWeb.PageRenderer do
router: BerrypodWeb.Router,
statics: BerrypodWeb.static_paths()
+ import BerrypodWeb.BlockEditorComponents
+ import BerrypodWeb.CoreComponents, only: [icon: 1]
+
alias Berrypod.Cart
# ── Public API ──────────────────────────────────────────────────
@@ -24,8 +27,19 @@ defmodule BerrypodWeb.PageRenderer do
Expects `@page` (with `:slug` and `:blocks`) plus all the standard
layout assigns (theme_settings, cart_items, etc.).
+
+ When `@editing` is true and `@editing_blocks` is set (admin using the
+ live page editor), wraps the page in a sidebar + content layout.
"""
def render_page(assigns) do
+ if assigns[:editing] && assigns[:editing_blocks] do
+ render_page_with_editor(assigns)
+ else
+ render_page_normal(assigns)
+ end
+ end
+
+ defp render_page_normal(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
@@ -43,6 +57,122 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
+ defp render_page_with_editor(assigns) do
+ assigns = assign(assigns, :block_assigns, block_assigns(assigns))
+
+ ~H"""
+
+
+
+ <%!-- Backdrop: tapping the page dismisses the sidebar --%>
+
+
+
+ <.shop_layout
+ {layout_assigns(assigns)}
+ active_page={@page.slug}
+ error_page={@page.slug == "error"}
+ >
+
+
+ {render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
+
+
+
+
+
+ """
+ end
+
# ── Block dispatch ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "hero"}} = assigns) do
@@ -429,11 +559,13 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{}
+ content = settings["content"] || ""
assigns =
assigns
|> assign(:image_src, settings["image_src"])
|> assign(:image_alt, settings["image_alt"] || "")
+ |> assign(:content, content)
~H"""
@@ -449,7 +581,17 @@ defmodule BerrypodWeb.PageRenderer do
<% end %>
- <.rich_text blocks={assigns[:content_blocks] || []} />
+ <%= if @content != "" do %>
+ <%!-- Inline content from block settings — split on blank lines for paragraphs --%>
+
+ <% else %>
+ <%!-- Structured rich text from LiveView (content pages) --%>
+ <.rich_text blocks={assigns[:content_blocks] || []} />
+ <% end %>
"""
end
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index 43b3ebb..e41f09d 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -70,7 +70,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.ThemeHook, :require_site_live},
{BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search},
- {BerrypodWeb.AnalyticsHook, :track}
+ {BerrypodWeb.AnalyticsHook, :track},
+ {BerrypodWeb.PageEditorHook, :mount_page_editor}
] do
live "/", Shop.Home, :index
live "/about", Shop.Content, :about
diff --git a/test/berrypod/pages/block_editor_test.exs b/test/berrypod/pages/block_editor_test.exs
new file mode 100644
index 0000000..d8aa06b
--- /dev/null
+++ b/test/berrypod/pages/block_editor_test.exs
@@ -0,0 +1,187 @@
+defmodule Berrypod.Pages.BlockEditorTest do
+ use ExUnit.Case, async: true
+
+ alias Berrypod.Pages.BlockEditor
+
+ defp make_block(id, type, settings \\ %{}) do
+ %{"id" => id, "type" => type, "settings" => settings}
+ end
+
+ defp three_blocks do
+ [
+ make_block("a", "hero", %{"title" => "Welcome"}),
+ make_block("b", "featured_products"),
+ make_block("c", "image_text", %{"title" => "About us"})
+ ]
+ end
+
+ describe "move_up/2" do
+ test "moves a block up one position" do
+ {:ok, blocks, msg} = BlockEditor.move_up(three_blocks(), "b")
+
+ assert [%{"id" => "b"}, %{"id" => "a"}, %{"id" => "c"}] = blocks
+ assert msg =~ "moved to position"
+ end
+
+ test "returns :noop for the first block" do
+ assert :noop = BlockEditor.move_up(three_blocks(), "a")
+ end
+
+ test "returns :noop for unknown id" do
+ assert :noop = BlockEditor.move_up(three_blocks(), "zzz")
+ end
+ end
+
+ describe "move_down/2" do
+ test "moves a block down one position" do
+ {:ok, blocks, msg} = BlockEditor.move_down(three_blocks(), "b")
+
+ assert [%{"id" => "a"}, %{"id" => "c"}, %{"id" => "b"}] = blocks
+ assert msg =~ "moved to position"
+ end
+
+ test "returns :noop for the last block" do
+ assert :noop = BlockEditor.move_down(three_blocks(), "c")
+ end
+ end
+
+ describe "remove_block/2" do
+ test "removes the specified block" do
+ {:ok, blocks, msg} = BlockEditor.remove_block(three_blocks(), "b")
+
+ assert length(blocks) == 2
+ refute Enum.any?(blocks, &(&1["id"] == "b"))
+ assert msg =~ "removed"
+ end
+ end
+
+ describe "duplicate_block/2" do
+ test "inserts a copy after the original" do
+ {:ok, blocks, msg} = BlockEditor.duplicate_block(three_blocks(), "a")
+
+ assert length(blocks) == 4
+ assert Enum.at(blocks, 0)["id"] == "a"
+ # Copy is at index 1 with a new ID
+ copy = Enum.at(blocks, 1)
+ assert copy["type"] == "hero"
+ assert copy["id"] != "a"
+ assert copy["settings"] == %{"title" => "Welcome"}
+ assert msg =~ "duplicated"
+ end
+
+ test "returns :noop for unknown id" do
+ assert :noop = BlockEditor.duplicate_block(three_blocks(), "zzz")
+ end
+ end
+
+ describe "add_block/2" do
+ test "appends a new block of the given type" do
+ {:ok, blocks, msg} = BlockEditor.add_block(three_blocks(), "hero")
+
+ assert length(blocks) == 4
+ new = List.last(blocks)
+ assert new["type"] == "hero"
+ assert is_binary(new["id"])
+ assert msg =~ "added"
+ end
+
+ test "returns :noop for unknown block type" do
+ assert :noop = BlockEditor.add_block(three_blocks(), "nonexistent_block_type")
+ end
+ end
+
+ describe "update_settings/3" do
+ test "merges new settings into the block" do
+ {:ok, blocks} =
+ BlockEditor.update_settings(three_blocks(), "a", %{"title" => "New title"})
+
+ hero = Enum.find(blocks, &(&1["id"] == "a"))
+ assert hero["settings"]["title"] == "New title"
+ end
+
+ test "returns :noop for unknown block id" do
+ assert :noop = BlockEditor.update_settings(three_blocks(), "zzz", %{"title" => "Nope"})
+ end
+ end
+
+ describe "block_display_name/1" do
+ test "returns the block type name from registry" do
+ block = make_block("a", "hero")
+ assert BlockEditor.block_display_name(block) == "Hero banner"
+ end
+
+ test "returns the raw type for unknown types" do
+ block = make_block("a", "weird_thing")
+ assert BlockEditor.block_display_name(block) == "weird_thing"
+ end
+
+ test "handles nil" do
+ assert BlockEditor.block_display_name(nil) == "Block"
+ end
+ end
+
+ describe "has_settings?/1" do
+ test "returns true for blocks with settings schema" do
+ assert BlockEditor.has_settings?(make_block("a", "hero"))
+ end
+
+ test "returns false for blocks without settings" do
+ refute BlockEditor.has_settings?(make_block("a", "category_nav"))
+ end
+ end
+
+ describe "settings_with_defaults/1" do
+ test "fills in default values for missing settings" do
+ block = make_block("a", "hero", %{})
+ result = BlockEditor.settings_with_defaults(block)
+
+ # Hero has a title field with a default
+ assert is_binary(result["title"])
+ end
+
+ test "preserves existing settings" do
+ block = make_block("a", "hero", %{"title" => "Custom"})
+ result = BlockEditor.settings_with_defaults(block)
+
+ assert result["title"] == "Custom"
+ end
+ end
+
+ describe "coerce_settings/2" do
+ test "passes through string values" do
+ schema = [%{key: "title", type: :text, default: ""}]
+ result = BlockEditor.coerce_settings(%{"title" => "Hello"}, schema)
+ assert result["title"] == "Hello"
+ end
+
+ test "parses number fields" do
+ schema = [%{key: "columns", type: :number, default: 4}]
+ result = BlockEditor.coerce_settings(%{"columns" => "3"}, schema)
+ assert result["columns"] == 3
+ end
+
+ test "falls back to default for invalid numbers" do
+ schema = [%{key: "columns", type: :number, default: 4}]
+ result = BlockEditor.coerce_settings(%{"columns" => "abc"}, schema)
+ assert result["columns"] == 4
+ end
+ end
+
+ describe "parse_number/2" do
+ test "parses valid integer strings" do
+ assert BlockEditor.parse_number("42", 0) == 42
+ end
+
+ test "returns integer values as-is" do
+ assert BlockEditor.parse_number(7, 0) == 7
+ end
+
+ test "returns default for non-numeric strings" do
+ assert BlockEditor.parse_number("nope", 5) == 5
+ end
+
+ test "returns default for nil" do
+ assert BlockEditor.parse_number(nil, 10) == 10
+ end
+ end
+end
diff --git a/test/berrypod_web/page_editor_hook_test.exs b/test/berrypod_web/page_editor_hook_test.exs
new file mode 100644
index 0000000..5e19af9
--- /dev/null
+++ b/test/berrypod_web/page_editor_hook_test.exs
@@ -0,0 +1,231 @@
+defmodule BerrypodWeb.PageEditorHookTest do
+ use BerrypodWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import Berrypod.AccountsFixtures
+ import Berrypod.ProductsFixtures
+
+ alias Berrypod.Pages
+ alias Berrypod.Pages.PageCache
+
+ setup do
+ PageCache.invalidate_all()
+ user = user_fixture()
+ {:ok, _} = Berrypod.Settings.set_site_live(true)
+
+ provider_conn = provider_connection_fixture()
+
+ product =
+ product_fixture(%{
+ provider_connection: provider_conn,
+ title: "Test Product",
+ category: "Test Category"
+ })
+
+ product_variant_fixture(%{product: product, title: "Standard", price: 1999})
+ Berrypod.Products.recompute_cached_fields(product)
+
+ %{user: user, product: product}
+ end
+
+ describe "non-admin cannot access edit mode" do
+ test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/?edit=true")
+
+ refute html =~ "page-editor-sidebar"
+ refute html =~ "page-editor-live"
+ end
+ end
+
+ describe "edit button visibility" do
+ test "admin sees edit pencil in shop header", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, _view, html} = live(conn, "/")
+
+ assert html =~ "Edit page"
+ end
+
+ test "non-admin does not see edit pencil", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/")
+
+ refute html =~ "Edit page"
+ end
+ end
+
+ describe "entering and exiting edit mode" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "admin enters edit mode with ?edit=true", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ assert has_element?(view, ".page-editor-sidebar")
+ assert has_element?(view, ".page-editor-content")
+ assert has_element?(view, ".block-card")
+ end
+
+ test "sidebar shows the page title", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ assert has_element?(view, ".page-editor-sidebar-title", "Home page")
+ end
+
+ test "done button exits edit mode", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # editor_done uses push_navigate, which causes a redirect
+ {:error, {:live_redirect, %{to: "/"}}} =
+ view |> element("button[phx-click='editor_done']") |> render_click()
+ end
+
+ test "toggle sidebar hides and shows via header pencil", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # Sidebar starts open, pencil button in header is hidden
+ assert has_element?(view, "[data-sidebar-open='true']")
+
+ refute has_element?(
+ view,
+ "button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
+ )
+
+ # Close the sidebar via the X button
+ view
+ |> element("button[phx-click='editor_toggle_sidebar'][aria-label='Close sidebar']")
+ |> render_click()
+
+ assert has_element?(view, "[data-sidebar-open='false']")
+ # Pencil button appears in header to re-open
+ assert has_element?(
+ view,
+ "button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
+ )
+
+ # Re-open via pencil in header
+ view
+ |> element("button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']")
+ |> render_click()
+
+ assert has_element?(view, "[data-sidebar-open='true']")
+ end
+
+ test "clicking backdrop hides the sidebar", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # Backdrop present when sidebar is open
+ assert has_element?(view, ".page-editor-backdrop")
+
+ # Click backdrop to dismiss
+ view |> element(".page-editor-backdrop") |> render_click()
+
+ assert has_element?(view, "[data-sidebar-open='false']")
+ # Backdrop gone when sidebar is hidden
+ refute has_element?(view, ".page-editor-backdrop")
+ end
+ end
+
+ describe "block manipulation in edit mode" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "move block down reorders the list", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # Home page default: hero is first block
+ first_card = view |> element(".block-card:first-child")
+ first_html = render(first_card)
+ assert first_html =~ "Hero"
+
+ # Get the hero block's ID and move it down
+ blocks = Pages.get_page("home").blocks
+ hero_id = List.first(blocks)["id"]
+
+ view
+ |> element("button[phx-click='editor_move_down'][phx-value-id='#{hero_id}']")
+ |> render_click()
+
+ # After move, hero is no longer the first card
+ updated_first = view |> element(".block-card:first-child") |> render()
+ refute updated_first =~ "Hero"
+ end
+
+ test "dirty indicator appears after changes", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
+
+ # Move a block to trigger dirty state
+ blocks = Pages.get_page("home").blocks
+ second_id = Enum.at(blocks, 1)["id"]
+
+ view
+ |> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
+ |> render_click()
+
+ assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
+ end
+ end
+
+ describe "save and reset" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "save persists block changes", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # Move a block to make changes
+ blocks = Pages.get_page("home").blocks
+ original_first_type = List.first(blocks)["type"]
+ second_id = Enum.at(blocks, 1)["id"]
+
+ view
+ |> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
+ |> render_click()
+
+ # Save
+ view |> element("button[phx-click='editor_save']") |> render_click()
+
+ assert has_element?(view, "#shop-flash-info", "Page saved")
+
+ # Verify persistence
+ updated = Pages.get_page("home")
+ refute List.first(updated.blocks)["type"] == original_first_type
+ end
+
+ test "reset restores default blocks", %{conn: conn} do
+ # First, save a modified page
+ original = Pages.get_page("home")
+ reordered = Enum.reverse(original.blocks)
+ Pages.save_page("home", %{title: original.title, blocks: reordered})
+ PageCache.invalidate_all()
+
+ {:ok, view, _html} = live(conn, "/?edit=true")
+
+ # Reset
+ view |> element("button[phx-click='editor_reset_defaults']") |> render_click()
+
+ assert has_element?(view, "#shop-flash-info", "Page reset to defaults")
+
+ # Verify the blocks are back to defaults
+ reset_page = Pages.get_page("home")
+ assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"]
+ end
+ end
+
+ describe "content pages (deferred init)" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "editing works on about page via deferred init", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/about?edit=true")
+
+ assert has_element?(view, ".page-editor-sidebar")
+ assert has_element?(view, ".page-editor-sidebar-title", "About")
+ assert has_element?(view, ".block-card")
+ end
+ end
+end