diff --git a/PROGRESS.md b/PROGRESS.md
index feb9d23..08bc40a 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -89,8 +89,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme
|---|---|---|---|
| 1 | Add theme editing state to PageEditorHook | 2h | done |
| 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | done |
-| 3 | Extract theme editor into reusable component | 3h | planned |
-| 3b | Create settings editor component | 2h | planned |
+| 3 | Extract theme editor into reusable component | 3h | done |
+| 3b | Create settings editor component | 2h | done |
| 4 | Image upload handling in hook context | 2h | planned |
| 5 | URL-based mode activation (?edit=theme) | 1h | planned |
| 6 | Admin routing redirect | 30m | planned |
diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index 54f882b..e687f61 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -4600,6 +4600,72 @@
/* Last group in a customise section (no border, tighter margin than theme-group) */
.theme-group-flush { margin-bottom: 1rem; }
+/* ── Settings editor (on-site editor Settings tab) ── */
+
+.settings-editor-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.settings-slug-preview {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.settings-slug-preview .admin-input {
+ flex: 1;
+}
+
+.settings-nav-options {
+ padding-left: 1rem;
+ border-left: 2px solid var(--admin-border);
+}
+
+.settings-nav-row {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.settings-nav-field {
+ flex: 1;
+}
+
+.settings-nav-field-sm {
+ flex: 0 0 5rem;
+}
+
+.settings-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding-top: 0.5rem;
+}
+
+.settings-save-indicator {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ color: var(--admin-success);
+}
+
+.settings-save-error {
+ color: var(--admin-error);
+}
+
+.settings-admin-link {
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--admin-border);
+}
+
+.settings-info-view {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
/* ── Content width containers ── */
.admin-content-narrow { max-width: 32rem; }
diff --git a/lib/berrypod_web/components/shop_components.ex b/lib/berrypod_web/components/shop_components.ex
index 4215125..f9fa0d2 100644
--- a/lib/berrypod_web/components/shop_components.ex
+++ b/lib/berrypod_web/components/shop_components.ex
@@ -10,6 +10,7 @@ defmodule BerrypodWeb.ShopComponents do
- `Product` — product cards, gallery, variant selector, hero sections
- `Content` — rich text, responsive images, contact form, reviews
- `ThemeEditor` — shared theme editor components for admin and on-site editing
+ - `SettingsEditor` — shared settings editor components for on-site editing
"""
defmacro __using__(_opts \\ []) do
@@ -19,6 +20,7 @@ defmodule BerrypodWeb.ShopComponents do
import BerrypodWeb.ShopComponents.Content
import BerrypodWeb.ShopComponents.Layout
import BerrypodWeb.ShopComponents.Product
+ import BerrypodWeb.ShopComponents.SettingsEditor
import BerrypodWeb.ShopComponents.ThemeEditor
end
end
diff --git a/lib/berrypod_web/components/shop_components/settings_editor.ex b/lib/berrypod_web/components/shop_components/settings_editor.ex
new file mode 100644
index 0000000..e8a7f01
--- /dev/null
+++ b/lib/berrypod_web/components/shop_components/settings_editor.ex
@@ -0,0 +1,340 @@
+defmodule BerrypodWeb.ShopComponents.SettingsEditor do
+ @moduledoc """
+ Shared settings editor components used in the on-site editor panel.
+
+ Shows context-specific settings based on the current page type:
+ - Custom CMS pages: Title, slug, visibility, SEO meta, navigation
+ - System pages (home, about, etc.): Page info only (editing done in admin)
+ - Product pages: Read-only product info, link to admin
+ - Collection pages: Read-only collection info, link to admin
+
+ Components emit events with a configurable prefix:
+ - `settings_update_page` (phx-change/phx-submit)
+
+ The event prefix is controlled by `@event_prefix`:
+ - `"settings_"` (default) for on-site editor context
+ """
+
+ use Phoenix.Component
+
+ import BerrypodWeb.CoreComponents, only: [icon: 1]
+
+ # ── Main Editor Component ────────────────────────────────────────────
+
+ @doc """
+ Renders the settings editor panel.
+
+ Content varies based on the page type (custom, system, product, collection).
+ """
+ attr :page, :map, default: nil
+ attr :product, :map, default: nil
+ attr :collection_title, :string, default: nil
+ attr :live_action, :atom, default: nil
+ attr :settings_form, :any, default: nil
+ attr :settings_dirty, :boolean, default: false
+ attr :settings_save_status, :atom, default: :idle
+ attr :event_prefix, :string, default: "settings_"
+
+ def settings_editor(assigns) do
+ ~H"""
+
+ <%= cond do %>
+ <% @live_action == :product and @product -> %>
+ <.product_settings product={@product} />
+ <% @live_action == :collection and @collection_title -> %>
+ <.collection_settings collection_title={@collection_title} />
+ <% @page && @page[:type] == "custom" -> %>
+ <.custom_page_settings
+ page={@page}
+ form={@settings_form}
+ dirty={@settings_dirty}
+ save_status={@settings_save_status}
+ event_prefix={@event_prefix}
+ />
+ <% @page -> %>
+ <.system_page_settings page={@page} />
+ <% true -> %>
+ <.no_settings_view />
+ <% end %>
+
+ """
+ end
+
+ # ── Custom Page Settings ─────────────────────────────────────────────
+
+ attr :page, :map, required: true
+ attr :form, :any, default: nil
+ attr :dirty, :boolean, default: false
+ attr :save_status, :atom, default: :idle
+ attr :event_prefix, :string, default: "settings_"
+
+ defp custom_page_settings(assigns) do
+ # Use form values if available, otherwise fall back to page values
+ form = assigns.form || %{}
+
+ assigns =
+ assigns
+ |> assign(:form_title, form["title"] || assigns.page.title || "")
+ |> assign(:form_slug, form["slug"] || assigns.page.slug || "")
+ |> assign(:form_meta, form["meta_description"] || assigns.page.meta_description || "")
+ |> assign(:form_published, form_checked?(form, "published", assigns.page.published))
+ |> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", assigns.page.show_in_nav))
+ |> assign(:form_nav_label, form["nav_label"] || assigns.page.nav_label || "")
+ |> assign(
+ :form_nav_position,
+ form["nav_position"] || to_string(assigns.page.nav_position || 0)
+ )
+
+ ~H"""
+
+ """
+ end
+
+ # ── System Page Settings ─────────────────────────────────────────────
+
+ attr :page, :map, required: true
+
+ defp system_page_settings(assigns) do
+ ~H"""
+
+ """
+ end
+
+ # ── Product Page Settings ────────────────────────────────────────────
+
+ attr :product, :map, required: true
+
+ defp product_settings(assigns) do
+ ~H"""
+
+
+
+
{@product.title}
+
+
+
+
+
{provider_label(@product)}
+
+
+
+
+ Product details are synced from your print provider.
+ View in admin
+ to see pricing, variants, and images.
+
+
+
+ """
+ end
+
+ defp provider_label(product) do
+ case product.provider_type do
+ "printify" -> "Printify"
+ "printful" -> "Printful"
+ _ -> "Unknown"
+ end
+ end
+
+ # ── Collection Page Settings ─────────────────────────────────────────
+
+ attr :collection_title, :string, required: true
+
+ defp collection_settings(assigns) do
+ ~H"""
+
+
+
+
{@collection_title}
+
+
+
+
+ Collection pages show products filtered by category.
+ Edit the page layout using the Page tab.
+
+
+
+ """
+ end
+
+ # ── No Settings View ─────────────────────────────────────────────────
+
+ defp no_settings_view(assigns) do
+ ~H"""
+
+
+
+ This page doesn't have editable settings.
+ Shop settings
+ can be changed in admin.
+
+
+
+ """
+ end
+
+ # ── Helper Functions ─────────────────────────────────────────────────
+
+ # Check if a form checkbox should be checked
+ # Handles both form params (strings) and page values (booleans)
+ defp form_checked?(form, key, page_value) when is_map(form) do
+ case form[key] do
+ "true" -> true
+ "false" -> false
+ nil -> page_value == true
+ _ -> page_value == true
+ end
+ end
+
+ # ── Helper Components ────────────────────────────────────────────────
+
+ attr :status, :atom, required: true
+
+ defp save_status_indicator(assigns) do
+ ~H"""
+
+ <.icon name="hero-check-mini" class="size-4" /> Saved
+
+
+ <.icon name="hero-exclamation-circle-mini" class="size-4" /> Error
+
+ """
+ end
+end
diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex
index ab37712..21964c9 100644
--- a/lib/berrypod_web/page_editor_hook.ex
+++ b/lib/berrypod_web/page_editor_hook.ex
@@ -61,6 +61,9 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:theme_editor_customise_open, false)
|> assign(:theme_editor_presets, Presets.all_with_descriptions())
+ # Settings editing state
+ |> assign(:settings_dirty, false)
+ |> assign(:settings_save_status, :idle)
|> 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)
@@ -212,6 +215,16 @@ defmodule BerrypodWeb.PageEditorHook do
load_theme_state(socket)
end
+ # Initialize settings form from current page if not already set
+ # Only custom pages have editable settings (meta_description, published, etc.)
+ socket =
+ if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] &&
+ socket.assigns.page[:type] == "custom" do
+ init_settings_form(socket)
+ else
+ socket
+ end
+
assign(socket, :editor_active_tab, :settings)
end
@@ -251,6 +264,15 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
+ # Settings editing events (custom page settings)
+ defp handle_editor_event("settings_" <> action, params, socket) do
+ if socket.assigns.is_admin do
+ handle_settings_action(action, params, socket)
+ else
+ {:cont, socket}
+ end
+ end
+
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# ── Block manipulation actions ───────────────────────────────────
@@ -721,6 +743,106 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
+ # ── Settings editing actions (custom page settings) ────────────────
+
+ # Validate page settings form (live as-you-type)
+ defp handle_settings_action("validate_page", %{"page" => params}, socket) do
+ page = socket.assigns.page
+
+ # Only allow editing custom pages
+ if page && page.type == "custom" do
+ socket =
+ socket
+ |> assign(:settings_form, params)
+ |> assign(:settings_dirty, has_settings_changed?(page, params))
+ |> assign(:settings_save_status, :idle)
+
+ {:halt, socket}
+ else
+ {:halt, socket}
+ end
+ end
+
+ # Save page settings
+ defp handle_settings_action("save_page", %{"page" => params}, socket) do
+ page = socket.assigns.page
+
+ # Only allow editing custom pages
+ if page && page.type == "custom" do
+ # Normalize checkbox fields (unchecked checkboxes aren't sent)
+ params =
+ params
+ |> Map.put_new("published", "false")
+ |> Map.put_new("show_in_nav", "false")
+
+ old_slug = page.slug
+
+ # Fetch the Page struct from DB (assigns.page may be a map from cache)
+ page_struct = Pages.get_page_struct(page.slug)
+
+ case Pages.update_custom_page(page_struct, params) do
+ {:ok, updated_page} ->
+ # Reinitialize form from saved page
+ socket =
+ socket
+ |> assign(:page, updated_page)
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+ |> assign(:settings_save_status, :saved)
+
+ # Reinit form with new page values
+ socket = init_settings_form(socket)
+
+ # If slug changed, redirect to new URL
+ socket =
+ if updated_page.slug != old_slug do
+ push_navigate(socket, to: "/#{updated_page.slug}")
+ else
+ socket
+ end
+
+ {:halt, socket}
+
+ {:error, _changeset} ->
+ socket = assign(socket, :settings_save_status, :error)
+ {:halt, socket}
+ end
+ else
+ {:halt, socket}
+ end
+ end
+
+ # Catch-all for unknown settings actions
+ defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
+
+ # Check if settings have changed from current page values
+ defp has_settings_changed?(page, params) do
+ page.title != (params["title"] || "") or
+ page.slug != (params["slug"] || "") or
+ (page.meta_description || "") != (params["meta_description"] || "") or
+ to_string(page.published) != (params["published"] || "false") or
+ to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or
+ (page.nav_label || "") != (params["nav_label"] || "") or
+ to_string(page.nav_position || 0) != (params["nav_position"] || "0")
+ end
+
+ # Initialize settings form from page values
+ defp init_settings_form(socket) do
+ page = socket.assigns.page
+
+ form = %{
+ "title" => page.title || "",
+ "slug" => page.slug || "",
+ "meta_description" => page.meta_description || "",
+ "published" => to_string(page.published),
+ "show_in_nav" => to_string(page.show_in_nav),
+ "nav_label" => page.nav_label || "",
+ "nav_position" => to_string(page.nav_position || 0)
+ }
+
+ assign(socket, :settings_form, form)
+ end
+
# Helper to update a theme setting and regenerate CSS
defp update_theme_setting(socket, attrs, field) do
case Settings.update_theme_settings(attrs) do
diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex
index 7d19c6d..ba172fe 100644
--- a/lib/berrypod_web/page_renderer.ex
+++ b/lib/berrypod_web/page_renderer.ex
@@ -112,6 +112,12 @@ defmodule BerrypodWeb.PageRenderer do
theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])}
theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)}
site_name={Map.get(assigns, :site_name, "")}
+ product={assigns[:product]}
+ collection_title={assigns[:collection_title]}
+ live_action={assigns[:live_action]}
+ settings_form={Map.get(assigns, :settings_form)}
+ settings_dirty={Map.get(assigns, :settings_dirty, false)}
+ settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
/>
"""
@@ -138,6 +144,12 @@ defmodule BerrypodWeb.PageRenderer do
attr :theme_editor_presets, :list, default: []
attr :theme_editor_customise_open, :boolean, default: false
attr :site_name, :string, default: ""
+ attr :product, :map, default: nil
+ attr :collection_title, :string, default: nil
+ attr :live_action, :atom, default: nil
+ attr :settings_form, :map, default: nil
+ attr :settings_dirty, :boolean, default: false
+ attr :settings_save_status, :atom, default: :idle
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
~H"""
@@ -174,7 +186,15 @@ defmodule BerrypodWeb.PageRenderer do
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
~H"""
- <.settings_editor_content page={@page} site_name={@site_name} />
+
"""
end
@@ -198,37 +218,6 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
- # Settings editor content - shows page/shop settings
- attr :page, :map, default: nil
- attr :site_name, :string, default: ""
-
- defp settings_editor_content(assigns) do
- ~H"""
-
- <%= if @page do %>
-
-
-
- Page settings like SEO, visibility, and slug editing coming soon.
- For now, manage pages in admin.
-
-
- <% else %>
-
-
- This page doesn't have editable settings.
- Shop settings
- can be changed in admin.
-
-
- <% end %>
-
- """
- end
-
# Editor sheet content - the block list and editing controls
attr :page, :map, default: nil
attr :editing_blocks, :list, default: nil