add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s

- Add header and footer nav editors to Site tab with drag-to-reorder,
  add/remove items, and destination picker (pages, collections, external)
- Live preview updates as you edit nav items
- Remove legacy /admin/navigation page and controller (was saving to
  Settings table, now uses nav_items table)
- Update error_html.ex and pages/editor.ex to load nav from nav_items table
- Update link_scanner to read from nav_items table, edit path now /?edit=site
- Add Site.default_header_nav/0 and default_footer_nav/0 for previews/errors
- Remove fallback logic from theme_hook.ex (database is now source of truth)
- Seed default nav items and social links during setup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-28 22:19:48 +00:00
parent 5a5103bc42
commit 7c07805df8
24 changed files with 1068 additions and 1130 deletions

View File

@@ -118,14 +118,6 @@
<.icon name="hero-document" class="size-5" /> Pages
</.link>
</li>
<li>
<.link
navigate={~p"/admin/navigation"}
class={admin_nav_active?(@current_path, "/admin/navigation")}
>
<.icon name="hero-bars-3" class="size-5" /> Navigation
</.link>
</li>
<li>
<.link
navigate={~p"/admin/media"}

View File

@@ -88,18 +88,23 @@ defmodule BerrypodWeb.ShopComponents.Layout do
base = Map.take(assigns, @layout_keys)
# When site editor is active, use in-memory values for live preview
# The site_* assigns are the editor's working copies, while announcement_*
# and social_links are the database-loaded values from theme_hook
# The site_state assigns is the editor's working copy, while announcement_*,
# nav items, and social_links are the database-loaded values from theme_hook
# Only override when site_editing is true (editor has loaded site state)
if assigns[:site_editing] do
# Convert raw SocialLink structs to shop format
social_links = format_social_links_for_shop(assigns[:site_social_links] || [])
if assigns[:site_editing] && assigns[:site_state] do
state = assigns[:site_state]
# Convert raw structs to shop format
social_links = format_social_links_for_shop(state.social_links || [])
header_nav = format_nav_items_for_shop(state.header_nav || [])
footer_nav = format_nav_items_for_shop(state.footer_nav || [])
base
|> Map.put(:announcement_text, assigns[:site_announcement_text])
|> Map.put(:announcement_link, assigns[:site_announcement_link])
|> Map.put(:announcement_style, assigns[:site_announcement_style])
|> Map.put(:announcement_text, state.announcement_text)
|> Map.put(:announcement_link, state.announcement_link)
|> Map.put(:announcement_style, state.announcement_style)
|> Map.put(:social_links, social_links)
|> Map.put(:header_nav_items, header_nav)
|> Map.put(:footer_nav_items, footer_nav)
else
base
end
@@ -122,6 +127,41 @@ defmodule BerrypodWeb.ShopComponents.Layout do
end)
end
# Convert raw NavItem structs to the format expected by shop components
# Filters out items with empty labels (incomplete entries still being edited)
defp format_nav_items_for_shop(items) do
items
|> Enum.reject(fn item -> is_nil(item.label) or item.label == "" end)
|> Enum.map(fn item ->
slug = extract_slug_from_url(item.url)
base = %{
"label" => item.label,
"href" => item.url,
"slug" => slug
}
# Add active_slugs for Shop nav item to highlight on collection and pdp pages
if slug == "collection" do
Map.put(base, "active_slugs", ["collection", "pdp"])
else
base
end
end)
end
# Extract a slug from a URL for nav item matching
defp extract_slug_from_url(url) when is_binary(url) do
cond do
url == "/" -> "home"
String.starts_with?(url, "/collections") -> "collection"
String.starts_with?(url, "/products") -> "pdp"
true -> url |> String.trim_leading("/") |> String.split("/") |> List.first() || ""
end
end
defp extract_slug_from_url(_), do: ""
# Social
defp platform_display_label("instagram"), do: "Instagram"
defp platform_display_label("threads"), do: "Threads"

View File

@@ -21,34 +21,41 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
Shows collapsible sections for each category of site-wide content.
Expects assigns from the page editor hook:
- site_header_nav, site_footer_nav, site_social_links
- site_announcement_text, site_announcement_link, site_announcement_style
- site_footer_about, site_footer_copyright, site_footer_show_newsletter
Expects:
- site_state: SiteEditorState struct with all site tab state
- site_nav_pages: list of pages for the nav picker
"""
attr :site_header_nav, :list, default: []
attr :site_footer_nav, :list, default: []
attr :site_social_links, :list, default: []
attr :site_announcement_text, :string, default: ""
attr :site_announcement_link, :string, default: ""
attr :site_announcement_style, :string, default: "info"
attr :site_footer_about, :string, default: ""
attr :site_footer_copyright, :string, default: ""
attr :site_footer_show_newsletter, :boolean, default: true
attr :site_state, :any, required: true
attr :site_nav_pages, :list, default: []
attr :event_prefix, :string, default: "site_"
def site_editor(%{site_state: nil} = assigns) do
~H"""
<div class="editor-site-content">
<p class="admin-text-secondary">Loading site settings...</p>
</div>
"""
end
def site_editor(assigns) do
# Build settings map for child components
state = assigns.site_state
# Extract fields from state for child components
settings = %{
announcement_text: assigns.site_announcement_text,
announcement_link: assigns.site_announcement_link,
announcement_style: assigns.site_announcement_style,
footer_about: assigns.site_footer_about,
footer_copyright: assigns.site_footer_copyright,
show_newsletter: assigns.site_footer_show_newsletter
announcement_text: state.announcement_text,
announcement_link: state.announcement_link,
announcement_style: state.announcement_style,
footer_about: state.footer_about,
footer_copyright: state.footer_copyright,
show_newsletter: state.show_newsletter
}
assigns = assign(assigns, :settings, settings)
assigns =
assigns
|> assign(:settings, settings)
|> assign(:header_nav, state.header_nav)
|> assign(:footer_nav, state.footer_nav)
|> assign(:social_links, state.social_links)
~H"""
<div class="editor-site-content">
@@ -61,7 +68,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
</.site_section>
<.site_section title="Header navigation" icon="hero-bars-3">
<.nav_list_placeholder items={@site_header_nav} location="header" />
<.nav_editor
items={@header_nav}
location="header"
pages={@site_nav_pages}
event_prefix={@event_prefix}
/>
</.site_section>
<.site_section title="Footer" icon="hero-document-text">
@@ -69,11 +81,16 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
</.site_section>
<.site_section title="Footer navigation" icon="hero-queue-list">
<.nav_list_placeholder items={@site_footer_nav} location="footer" />
<.nav_editor
items={@footer_nav}
location="footer"
pages={@site_nav_pages}
event_prefix={@event_prefix}
/>
</.site_section>
<.site_section title="Social links" icon="hero-link">
<.social_links_editor links={@site_social_links} event_prefix={@event_prefix} />
<.social_links_editor links={@social_links} event_prefix={@event_prefix} />
</.site_section>
</div>
"""
@@ -93,7 +110,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
# Use phx-hook to preserve open state across re-renders
~H"""
<details id={@id} class="site-editor-section" phx-hook="DetailsPreserver" {if @open, do: [open: true], else: []}>
<details
id={@id}
class="site-editor-section"
phx-hook="DetailsPreserver"
{if @open, do: [open: true], else: []}
>
<summary class="site-editor-section-header">
<.icon :if={@icon} name={@icon} class="size-4" />
<span>{@title}</span>
@@ -238,24 +260,131 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
"""
end
# ── Navigation List (placeholder) ───────────────────────────────────
# ── Navigation Editor ────────────────────────────────────────────────
attr :items, :list, required: true
attr :location, :string, required: true
attr :pages, :list, default: []
attr :event_prefix, :string, default: "site_"
defp nav_list_placeholder(assigns) do
defp nav_editor(assigns) do
~H"""
<div class="site-editor-nav-list">
<ul class="site-editor-nav-items">
<li :for={item <- @items} class="site-editor-nav-item">
<span class="site-editor-nav-label">{item.label}</span>
<span class="site-editor-nav-url admin-text-tertiary">{item.url}</span>
<ul :if={@items != []} class="site-editor-nav-items">
<li
:for={{item, index} <- Enum.with_index(@items)}
class="site-editor-nav-item"
data-item-id={item.id}
>
<form
class="site-editor-nav-item-form"
phx-change={@event_prefix <> "update_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
>
<input
type="text"
name={"nav_item[#{item.id}][label]"}
value={item.label}
class="admin-input admin-input-sm site-editor-nav-label-input"
placeholder="Label"
phx-debounce="300"
aria-label="Link label"
/>
<div class="site-editor-nav-url-row">
<select
name={"nav_item[#{item.id}][link_type]"}
class="admin-select admin-select-sm site-editor-nav-type"
aria-label="Link type"
>
<option value="page" selected={item.page_id != nil}>Page</option>
<option value="url" selected={item.page_id == nil}>Custom URL</option>
</select>
<%= if item.page_id != nil or (item.url == "" and @pages != []) do %>
<select
name={"nav_item[#{item.id}][page_id]"}
class="admin-select admin-select-sm site-editor-nav-page"
aria-label="Page"
>
<option value="">Select a page</option>
<optgroup label="Pages">
<option
:for={page <- @pages}
value={page.slug}
selected={item.url == "/#{page.slug}" or item.url == page.slug}
>
{page.title}
</option>
</optgroup>
<optgroup label="Collections">
<option value="/collections/all" selected={item.url == "/collections/all"}>
All products
</option>
</optgroup>
</select>
<% else %>
<input
type="text"
name={"nav_item[#{item.id}][url]"}
value={item.url}
class="admin-input admin-input-sm site-editor-nav-url-input"
placeholder="/about or https://..."
phx-debounce="300"
aria-label="URL"
/>
<% end %>
</div>
</form>
<div class="site-editor-nav-item-actions">
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
phx-value-dir="up"
disabled={index == 0}
aria-label="Move up"
>
<.icon name="hero-chevron-up-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
phx-value-dir="down"
disabled={index == length(@items) - 1}
aria-label="Move down"
>
<.icon name="hero-chevron-down-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button admin-icon-button-danger"
phx-click={@event_prefix <> "remove_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
aria-label="Remove"
>
<.icon name="hero-x-mark-mini" class="size-4" />
</button>
</div>
</li>
</ul>
<p :if={@items == []} class="admin-text-tertiary">No navigation items</p>
<p class="admin-help-text">
Drag to reorder. Full editing coming in the next phase.
<p :if={@items == []} class="admin-text-tertiary admin-empty-message">
No navigation items yet
</p>
<button
type="button"
class="admin-button admin-button-sm admin-button-outline site-editor-add-button"
phx-click={@event_prefix <> "add_nav_item"}
phx-value-location={@location}
>
<.icon name="hero-plus-mini" class="size-4" />
<span>Add link</span>
</button>
</div>
"""
end
@@ -278,7 +407,11 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
class="site-editor-social-item"
data-link-id={link.id}
>
<form class="site-editor-social-item-content" phx-change={@event_prefix <> "update_social_link"} phx-value-id={link.id}>
<form
class="site-editor-social-item-content"
phx-change={@event_prefix <> "update_social_link"}
phx-value-id={link.id}
>
<input
type="text"
name={"social_link[#{link.id}][url]"}

View File

@@ -149,14 +149,8 @@ defmodule BerrypodWeb.ErrorHTML do
|> Map.put(:cart_count, 0)
|> Map.put(:cart_subtotal, "£0.00")
|> Map.put(:page, page)
|> Map.put(
:header_nav_items,
load_nav("header_nav", &BerrypodWeb.ThemeHook.default_header_nav/0)
)
|> Map.put(
:footer_nav_items,
load_nav("footer_nav", &BerrypodWeb.ThemeHook.default_footer_nav/0)
)
|> Map.put(:header_nav_items, load_nav_items("header"))
|> Map.put(:footer_nav_items, load_nav_items("footer"))
# Load block data (e.g. products for featured_products block)
extra = safe_load(fn -> Pages.load_block_data(page.blocks, assigns) end) || %{}
@@ -223,10 +217,15 @@ defmodule BerrypodWeb.ErrorHTML do
end
end
defp load_nav(key, default_fn) do
case safe_load(fn -> Settings.get_setting(key) end) do
items when is_list(items) -> items
_ -> default_fn.()
# Load nav items from database, falling back to defaults if DB fails
# (error pages might render when database is unavailable)
defp load_nav_items(location) do
case safe_load(fn -> Berrypod.Site.nav_items_for_shop(location) end) do
items when is_list(items) and items != [] -> items
_ -> default_nav_items(location)
end
end
defp default_nav_items("header"), do: Berrypod.Site.default_header_nav()
defp default_nav_items("footer"), do: Berrypod.Site.default_footer_nav()
end

View File

@@ -1,81 +0,0 @@
defmodule BerrypodWeb.NavigationController do
@moduledoc """
No-JS fallback for navigation form submission.
With JS enabled, the LiveView handles everything. Without JS,
the form POSTs here and we redirect back to the LiveView page.
"""
use BerrypodWeb, :controller
alias Berrypod.Settings
def save(conn, %{"header_nav" => header_json, "footer_nav" => footer_json}) do
with {:ok, header_items} <- Jason.decode(header_json),
{:ok, footer_items} <- Jason.decode(footer_json) do
all_items = header_items ++ footer_items
errors = validate_nav_items(all_items)
if errors == [] do
Settings.put_setting("header_nav", header_items, "json")
Settings.put_setting("footer_nav", footer_items, "json")
conn
|> put_flash(:info, "Navigation saved")
|> redirect(to: ~p"/admin/navigation")
else
conn
|> put_flash(:error, Enum.join(errors, ". "))
|> redirect(to: ~p"/admin/navigation")
end
else
{:error, _} ->
conn
|> put_flash(:error, "Invalid navigation data")
|> redirect(to: ~p"/admin/navigation")
end
end
defp validate_nav_items(items) do
items
|> Enum.flat_map(fn item ->
cond do
item["label"] == "" || item["label"] == nil ->
["All links need a label"]
item["href"] == "" || item["href"] == nil ->
["\"#{item["label"]}\" needs a destination"]
item["external"] == true || is_external_url?(item["href"]) ->
if valid_url?(item["href"]) do
[]
else
["\"#{item["label"]}\" has an invalid URL"]
end
true ->
[]
end
end)
|> Enum.uniq()
end
defp valid_url?(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
true
_ ->
false
end
end
defp valid_url?(_), do: false
defp is_external_url?(nil), do: false
defp is_external_url?(""), do: false
defp is_external_url?(href) do
String.starts_with?(href, "http://") || String.starts_with?(href, "https://")
end
end

View File

@@ -1,472 +0,0 @@
defmodule BerrypodWeb.Admin.Navigation do
use BerrypodWeb, :live_view
alias Berrypod.{Pages, Products, Settings}
alias BerrypodWeb.ThemeHook
# System pages that are always available
@system_pages [
%{href: "/", label: "Home"},
%{href: "/about", label: "About"},
%{href: "/contact", label: "Contact"},
%{href: "/cart", label: "Cart"},
%{href: "/search", label: "Search"},
%{href: "/delivery", label: "Delivery"},
%{href: "/privacy", label: "Privacy policy"},
%{href: "/terms", label: "Terms & conditions"}
]
@impl true
def mount(_params, _session, socket) do
header_items = load_nav("header_nav", ThemeHook.default_header_nav())
footer_items = load_nav("footer_nav", ThemeHook.default_footer_nav())
{:ok,
socket
|> assign(:page_title, "Navigation")
|> assign(:header_items, header_items)
|> assign(:footer_items, footer_items)
|> assign(:dirty, false)
|> assign(:save_status, :idle)
|> assign(:save_error, nil)
|> assign_link_options()}
end
defp assign_link_options(socket) do
custom_pages = Pages.list_custom_pages()
categories = Products.list_categories()
# Build grouped link options
link_options = [
{"Pages", Enum.map(@system_pages, &{&1.label, &1.href})},
{"Custom pages", Enum.map(custom_pages, fn p -> {p.title, "/#{p.slug}"} end)},
{"Collections", Enum.map(categories, fn c -> {c.name, "/collections/#{c.slug}"} end)},
{"Other", [{"External URL", "external"}]}
]
# Filter out empty groups
link_options = Enum.reject(link_options, fn {_label, opts} -> opts == [] end)
socket
|> assign(:link_options, link_options)
|> assign(:custom_pages, custom_pages)
|> assign(:categories, categories)
end
# ── Item manipulation ──────────────────────────────────────────
@impl true
def handle_event("nav_item_change", params, socket) do
section = params["section"]
index = String.to_integer(params["index"])
label = params["label"]
dest = params["dest"]
external_url = params["external_url"]
items = get_items(socket, section)
item = Enum.at(items, index)
if item do
updated =
cond do
# User selected "External URL"
dest == "external" ->
item
|> Map.put("label", label)
|> Map.put("href", external_url || "")
|> Map.put("external", true)
# User selected an internal link
dest != nil && dest != "" ->
# Auto-fill label if it was empty and user just selected a destination
auto_label =
if label == "" || label == item["label"],
do: label_for_href(dest, socket),
else: label
item
|> Map.put("label", if(label == "", do: auto_label, else: label))
|> Map.put("href", dest)
|> Map.delete("external")
# Just updating the label
true ->
Map.put(item, "label", label)
end
items = List.replace_at(items, index, updated)
{:noreply, put_items(socket, section, items) |> clear_save_status()}
else
{:noreply, socket}
end
end
def handle_event("remove_item", %{"section" => section, "index" => index_str}, socket) do
index = String.to_integer(index_str)
items = get_items(socket, section) |> List.delete_at(index)
{:noreply, put_items(socket, section, items) |> clear_save_status()}
end
def handle_event(
"move_item",
%{"section" => section, "index" => index_str, "dir" => dir},
socket
) do
index = String.to_integer(index_str)
items = get_items(socket, section)
target = if dir == "up", do: index - 1, else: index + 1
if target >= 0 and target < length(items) do
item = Enum.at(items, index)
items = items |> List.delete_at(index) |> List.insert_at(target, item)
{:noreply, put_items(socket, section, items) |> clear_save_status()}
else
{:noreply, socket}
end
end
def handle_event("add_item", %{"section" => section}, socket) do
# Add a new empty item - user will pick destination from dropdown
items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
{:noreply, put_items(socket, section, items) |> clear_save_status()}
end
def handle_event("save", _params, socket) do
all_items = socket.assigns.header_items ++ socket.assigns.footer_items
errors = validate_nav_items(all_items)
if errors == [] do
Settings.put_setting("header_nav", socket.assigns.header_items, "json")
Settings.put_setting("footer_nav", socket.assigns.footer_items, "json")
{:noreply,
socket
|> assign(:dirty, false)
|> assign(:save_status, :saved)
|> assign(:save_error, nil)}
else
{:noreply,
socket
|> assign(:save_status, :error)
|> assign(:save_error, Enum.join(errors, ". "))}
end
end
def handle_event("reset_defaults", _params, socket) do
{:noreply,
socket
|> assign(:header_items, ThemeHook.default_header_nav())
|> assign(:footer_items, ThemeHook.default_footer_nav())
|> assign(:dirty, true)
|> clear_save_status()}
end
# ── Private helpers for events ───────────────────────────────────
defp label_for_href(href, socket) do
# Check system pages
case Enum.find(@system_pages, &(&1.href == href)) do
%{label: label} ->
label
nil ->
# Check custom pages
case Enum.find(socket.assigns.custom_pages, &("/#{&1.slug}" == href)) do
%{title: title} ->
title
nil ->
# Check collections
case Enum.find(socket.assigns.categories, &("/collections/#{&1.slug}" == href)) do
%{name: name} -> name
nil -> ""
end
end
end
end
defp validate_nav_items(items) do
items
|> Enum.with_index()
|> Enum.flat_map(fn {item, _idx} ->
cond do
# Empty label
item["label"] == "" || item["label"] == nil ->
["All links need a label"]
# Empty href
item["href"] == "" || item["href"] == nil ->
["\"#{item["label"]}\" needs a destination"]
# External URL validation
item["external"] == true || is_external_url?(item["href"]) ->
if valid_url?(item["href"]) do
[]
else
["\"#{item["label"]}\" has an invalid URL"]
end
true ->
[]
end
end)
|> Enum.uniq()
end
defp valid_url?(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
true
_ ->
false
end
end
defp valid_url?(_), do: false
defp clear_save_status(socket) do
socket
|> assign(:save_status, :idle)
|> assign(:save_error, nil)
end
# ── Render ─────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="nav-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
<.header>
Navigation
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
</.header>
<div class="admin-nav-layout">
<p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes
</p>
<.nav_section
title="Header navigation"
section="header"
items={@header_items}
link_options={@link_options}
/>
<.nav_section
title="Footer navigation"
section="footer"
items={@footer_items}
link_options={@link_options}
/>
<form
action={~p"/admin/navigation"}
method="post"
phx-submit="save"
class="admin-row admin-row-lg"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input type="hidden" name="header_nav" value={Jason.encode!(@header_items)} />
<input type="hidden" name="footer_nav" value={Jason.encode!(@footer_items)} />
<button
type="submit"
class="admin-btn admin-btn-primary"
disabled={!@dirty}
phx-disable-with="Saving..."
>
Save
</button>
<.inline_feedback status={@save_status} message={@save_error} />
</form>
<div class="admin-row">
<button
type="button"
phx-click="reset_defaults"
data-confirm="Reset navigation to defaults? Your changes will be lost."
class="admin-btn admin-btn-ghost"
>
Reset to defaults
</button>
</div>
</div>
</div>
"""
end
defp nav_section(assigns) do
~H"""
<section>
<h3 class="admin-nav-section-heading">
{@title}
</h3>
<div class="admin-stack admin-stack-sm">
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
<span>Label</span>
<span>Destination</span>
</div>
<.nav_item
:for={{item, idx} <- Enum.with_index(@items)}
item={item}
idx={idx}
section={@section}
total={length(@items)}
link_options={@link_options}
/>
</div>
<div :if={@items == []} class="admin-nav-empty">
No items yet.
</div>
<div class="admin-nav-actions">
<button
phx-click="add_item"
phx-value-section={@section}
class="admin-btn admin-btn-sm admin-btn-outline"
>
<.icon name="hero-plus" class="size-4" /> Add link
</button>
</div>
</section>
"""
end
defp nav_item(assigns) do
# Determine if this is an external link
is_external = assigns.item["external"] == true || is_external_url?(assigns.item["href"])
selected_value = if is_external, do: "external", else: assigns.item["href"]
# Check if external URL is valid (only if there's a value)
url_invalid =
is_external && assigns.item["href"] != "" && assigns.item["href"] != nil &&
!valid_url?(assigns.item["href"])
assigns =
assigns
|> assign(:is_external, is_external)
|> assign(:selected_value, selected_value)
|> assign(:url_invalid, url_invalid)
~H"""
<form
class="nav-editor-item"
phx-change="nav_item_change"
phx-value-section={@section}
phx-value-index={@idx}
>
<div class="nav-editor-fields">
<label class="sr-only" for={"nav-#{@section}-#{@idx}-label"}>Label</label>
<input
type="text"
id={"nav-#{@section}-#{@idx}-label"}
name="label"
value={@item["label"]}
placeholder="Label"
class="admin-input nav-editor-input"
/>
<label class="sr-only" for={"nav-#{@section}-#{@idx}-dest"}>Destination</label>
<select
id={"nav-#{@section}-#{@idx}-dest"}
name="dest"
class="admin-input nav-editor-select"
>
<option value="" disabled selected={@selected_value == ""}>
Choose destination...
</option>
<%= for {group_label, options} <- @link_options do %>
<optgroup label={group_label}>
<%= for {label, href} <- options do %>
<option value={href} selected={@selected_value == href}>
{label}
</option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<%!-- External URL input (shown when "External URL" is selected) --%>
<div :if={@is_external} class="nav-editor-external">
<label class="sr-only" for={"nav-#{@section}-#{@idx}-url"}>URL</label>
<input
type="url"
id={"nav-#{@section}-#{@idx}-url"}
name="external_url"
value={@item["href"]}
placeholder="https://example.com"
class={["admin-input nav-editor-input", @url_invalid && "admin-input-error"]}
/>
<span :if={@url_invalid} class="nav-editor-error">
<.icon name="hero-exclamation-circle" class="size-4" />
Enter a valid URL starting with https://
</span>
</div>
<div class="nav-editor-actions">
<button
type="button"
phx-click="move_item"
phx-value-section={@section}
phx-value-index={@idx}
phx-value-dir="up"
disabled={@idx == 0}
class="admin-btn admin-btn-xs admin-btn-ghost"
aria-label="Move up"
>
<.icon name="hero-chevron-up" class="size-4" />
</button>
<button
type="button"
phx-click="move_item"
phx-value-section={@section}
phx-value-index={@idx}
phx-value-dir="down"
disabled={@idx == @total - 1}
class="admin-btn admin-btn-xs admin-btn-ghost"
aria-label="Move down"
>
<.icon name="hero-chevron-down" class="size-4" />
</button>
<button
type="button"
phx-click="remove_item"
phx-value-section={@section}
phx-value-index={@idx}
class="admin-btn admin-btn-xs admin-btn-ghost"
aria-label="Remove"
>
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
</form>
"""
end
defp is_external_url?(nil), do: false
defp is_external_url?(""), do: false
defp is_external_url?(href) do
String.starts_with?(href, "http://") || String.starts_with?(href, "https://")
end
# ── Helpers ────────────────────────────────────────────────────
defp get_items(socket, "header"), do: socket.assigns.header_items
defp get_items(socket, "footer"), do: socket.assigns.footer_items
defp put_items(socket, "header", items),
do: socket |> assign(:header_items, items) |> assign(:dirty, true)
defp put_items(socket, "footer", items),
do: socket |> assign(:footer_items, items) |> assign(:dirty, true)
defp load_nav(key, default) do
case Settings.get_setting(key) do
items when is_list(items) -> items
_ -> default
end
end
end

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{LegalPages, Media, Pages, Products}
alias Berrypod.{LegalPages, Media, Pages, Products, Site}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@@ -736,8 +736,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:cart_count, 2)
|> assign(:cart_subtotal, "£72.00")
|> assign(:cart_drawer_open, false)
|> assign(:header_nav_items, BerrypodWeb.ThemeHook.default_header_nav())
|> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav())
|> assign(:header_nav_items, load_nav_items("header"))
|> assign(:footer_nav_items, load_nav_items("footer"))
|> preview_page_context(assigns.slug)
extra = Pages.load_block_data(page.blocks, preview)
@@ -853,4 +853,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:save_status, :idle)
|> assign(:live_region_message, message)
end
# Load nav items from database for preview, falling back to defaults if empty
defp load_nav_items(location) do
case Site.nav_items_for_shop(location) do
[] -> default_nav_items(location)
items -> items
end
end
defp default_nav_items("header"), do: Site.default_header_nav()
defp default_nav_items("footer"), do: Site.default_footer_nav()
end

View File

@@ -20,10 +20,11 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
alias Berrypod.{Media, Settings, Site}
alias Berrypod.{Media, Settings}
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
alias BerrypodWeb.SiteEditorState
def on_mount(:mount_page_editor, _params, _session, socket) do
socket =
@@ -66,19 +67,11 @@ defmodule BerrypodWeb.PageEditorHook do
# Settings editing state
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :idle)
# Site editing state
# Site editing state (using SiteEditorState struct)
|> assign(:site_editing, false)
|> assign(:site_state, nil)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, nil)
|> assign(:site_header_nav, [])
|> assign(:site_footer_nav, [])
|> assign(:site_social_links, [])
|> assign(:site_announcement_text, "")
|> assign(:site_announcement_link, "")
|> assign(:site_announcement_style, "info")
|> assign(:site_footer_about, "")
|> assign(:site_footer_copyright, "")
|> assign(:site_footer_show_newsletter, true)
|> assign(:site_nav_pages, [])
# Navigation warning state
|> assign(:editor_nav_blocked, nil)
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
@@ -999,89 +992,46 @@ defmodule BerrypodWeb.PageEditorHook do
defp handle_site_action("update", _params, socket), do: {:halt, socket}
# Social link CRUD operations (persist immediately like images)
# Social link CRUD operations (using SiteEditorState)
defp handle_site_action("add_social_link", _params, socket) do
# Create with "custom" platform and blank URL
# User will paste their link, which auto-detects the platform
position = length(socket.assigns.site_social_links)
attrs = %{
platform: "custom",
url: "",
position: position
}
case Site.create_social_link(attrs) do
{:ok, link} ->
links = socket.assigns.site_social_links ++ [link]
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
socket = update_site_state(socket, &SiteEditorState.add_social_link/1)
{:halt, socket}
end
defp handle_site_action("update_social_link", %{"id" => id} = params, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
state = socket.assigns.site_state
link = Enum.find(state.social_links, &(&1.id == id))
if link do
# Extract the nested params from the form field name
link_params = params["social_link"][id] || %{}
target = params["_target"] || []
changed_field = List.last(target)
# Check what field was changed
url_changed? = List.last(target) == "url"
platform_changed? = List.last(target) == "platform"
# Build attrs based on what changed
attrs =
cond do
# URL changed - normalize and update URL, maybe auto-detect platform
url_changed? ->
case changed_field do
"url" ->
url = link_params["url"] |> Berrypod.Site.SocialLink.normalize_url()
detected = Berrypod.Site.SocialLink.detect_platform(url)
base = %{url: url}
# Auto-detect platform when:
# 1. Current platform is "custom" (initial state), OR
# 2. New URL detects to a different platform than currently set
# (e.g., changing from github.com to twitter.com)
should_update_platform? =
detected &&
detected != "custom" &&
(link.platform == "custom" || detected != link.platform)
if should_update_platform? do
Map.put(base, :platform, detected)
else
base
end
if should_update_platform?, do: Map.put(base, :platform, detected), else: base
# Platform explicitly changed by user - use their selection
platform_changed? ->
"platform" ->
%{platform: link_params["platform"]}
# Fallback
true ->
_ ->
%{}
|> maybe_put(:url, link_params["url"])
|> maybe_put(:platform, link_params["platform"])
end
if attrs != %{} do
case Site.update_social_link(link, attrs) do
{:ok, updated_link} ->
links =
Enum.map(socket.assigns.site_social_links, fn l ->
if l.id == id, do: updated_link, else: l
end)
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
socket = update_site_state(socket, &SiteEditorState.update_social_link(&1, id, attrs))
{:halt, socket}
else
{:halt, socket}
end
@@ -1091,119 +1041,149 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp handle_site_action("remove_social_link", %{"id" => id}, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
socket = update_site_state(socket, &SiteEditorState.remove_social_link(&1, id))
{:halt, socket}
end
if link do
case Site.delete_social_link(link) do
{:ok, _} ->
links = Enum.reject(socket.assigns.site_social_links, &(&1.id == id))
{:halt, assign(socket, :site_social_links, links)}
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
direction = if dir == "up", do: :up, else: :down
socket = update_site_state(socket, &SiteEditorState.move_social_link(&1, id, direction))
{:halt, socket}
end
{:error, _} ->
{:halt, socket}
# Navigation item CRUD operations (using SiteEditorState)
defp handle_site_action("add_nav_item", %{"location" => location}, socket) do
socket = update_site_state(socket, &SiteEditorState.add_nav_item(&1, location))
{:halt, socket}
end
defp handle_site_action(
"update_nav_item",
%{"id" => id, "location" => location} = params,
socket
) do
state = socket.assigns.site_state
items = if location == "header", do: state.header_nav, else: state.footer_nav
item = Enum.find(items, &(&1.id == id))
if item do
item_params = params["nav_item"][id] || %{}
target = params["_target"] || []
changed_field = List.last(target)
attrs =
case changed_field do
"label" ->
%{label: item_params["label"]}
"url" ->
%{url: item_params["url"]}
"link_type" ->
if item_params["link_type"] == "page", do: %{url: ""}, else: %{}
"page_id" ->
page_slug = item_params["page_id"]
if page_slug && page_slug != "" do
url = if String.starts_with?(page_slug, "/"), do: page_slug, else: "/#{page_slug}"
%{url: url}
else
%{}
end
_ ->
%{}
end
if attrs != %{} do
socket =
update_site_state(socket, &SiteEditorState.update_nav_item(&1, location, id, attrs))
{:halt, socket}
else
{:halt, socket}
end
else
{:halt, socket}
end
end
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
links = socket.assigns.site_social_links
index = Enum.find_index(links, &(&1.id == id))
defp handle_site_action("remove_nav_item", %{"id" => id, "location" => location}, socket) do
socket = update_site_state(socket, &SiteEditorState.remove_nav_item(&1, location, id))
{:halt, socket}
end
new_index =
case dir do
"up" -> max(0, index - 1)
"down" -> min(length(links) - 1, index + 1)
_ -> index
end
defp handle_site_action(
"move_nav_item",
%{"id" => id, "location" => location, "dir" => dir},
socket
) do
direction = if dir == "up", do: :up, else: :down
if index != new_index do
# Swap the items
item = Enum.at(links, index)
other = Enum.at(links, new_index)
socket =
update_site_state(socket, &SiteEditorState.move_nav_item(&1, location, id, direction))
reordered =
links
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
# Persist the new order
ids = Enum.map(reordered, & &1.id)
Site.reorder_social_links(ids)
{:halt, assign(socket, :site_social_links, reordered)}
else
{:halt, socket}
end
{:halt, socket}
end
# Catch-all for unknown site actions
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
defp handle_site_update(socket, params) do
# Handle announcement bar fields (preview only, no persistence)
socket =
state = socket.assigns.site_state
# Handle announcement bar fields
state =
if Map.has_key?(params, "announcement_text") or
Map.has_key?(params, "announcement_link") or
Map.has_key?(params, "announcement_style") do
text = params["announcement_text"] || socket.assigns.site_announcement_text
link = params["announcement_link"] || socket.assigns.site_announcement_link
style = params["announcement_style"] || socket.assigns.site_announcement_style
text = params["announcement_text"] || state.announcement_text
link = params["announcement_link"] || state.announcement_link
style = params["announcement_style"] || state.announcement_style
socket
|> assign(:site_announcement_text, text)
|> assign(:site_announcement_link, link)
|> assign(:site_announcement_style, style)
state
|> SiteEditorState.put(:announcement_text, text)
|> SiteEditorState.put(:announcement_link, link)
|> SiteEditorState.put(:announcement_style, style)
else
socket
state
end
# Handle footer fields (preview only, no persistence)
socket =
# Handle footer fields
state =
if Map.has_key?(params, "footer_about") or
Map.has_key?(params, "footer_copyright") or
Map.has_key?(params, "show_newsletter") do
about = params["footer_about"] || socket.assigns.site_footer_about
copyright = params["footer_copyright"] || socket.assigns.site_footer_copyright
about = params["footer_about"] || state.footer_about
copyright = params["footer_copyright"] || state.footer_copyright
# Checkbox sends value when checked, absent when unchecked
show_newsletter =
if Map.has_key?(params, "show_newsletter") do
params["show_newsletter"] == "true"
else
false
end
if Map.has_key?(params, "show_newsletter"),
do: params["show_newsletter"] == "true",
else: false
socket
|> assign(:site_footer_about, about)
|> assign(:site_footer_copyright, copyright)
|> assign(:site_footer_show_newsletter, show_newsletter)
state
|> SiteEditorState.put(:footer_about, about)
|> SiteEditorState.put(:footer_copyright, copyright)
|> SiteEditorState.put(:show_newsletter, show_newsletter)
else
socket
state
end
# Mark as dirty if values differ from original
socket
|> compute_site_dirty()
|> assign(:site_state, state)
|> assign(:site_dirty, SiteEditorState.dirty?(state))
end
defp compute_site_dirty(socket) do
original = socket.assigns[:site_editor_original]
# Helper to update site state and compute dirty flag
defp update_site_state(socket, update_fn) do
state = socket.assigns.site_state
new_state = update_fn.(state)
dirty =
if original do
socket.assigns.site_announcement_text != original.announcement_text or
socket.assigns.site_announcement_link != original.announcement_link or
socket.assigns.site_announcement_style != original.announcement_style or
socket.assigns.site_footer_about != original.footer_about or
socket.assigns.site_footer_copyright != original.footer_copyright or
socket.assigns.site_footer_show_newsletter != original.show_newsletter
else
false
end
assign(socket, :site_dirty, dirty)
socket
|> assign(:site_state, new_state)
|> assign(:site_dirty, SiteEditorState.dirty?(new_state))
end
# Check if settings have changed from current page values
@@ -1417,34 +1397,34 @@ defmodule BerrypodWeb.PageEditorHook do
# ── Site editing helpers ───────────────────────────────────────────
defp load_site_state(socket) do
site_settings = Site.get_settings()
# Store original values for revert capability
original = %{
announcement_text: site_settings.announcement_text,
announcement_link: site_settings.announcement_link,
announcement_style: site_settings.announcement_style,
footer_about: site_settings.footer_about,
footer_copyright: site_settings.footer_copyright,
show_newsletter: site_settings.show_newsletter
}
# Load pages for the page picker (system pages that are linkable + custom pages)
nav_pages = load_nav_pages()
socket
|> assign(:site_editing, true)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, original)
|> assign(:site_header_nav, Site.list_nav_items(:header))
|> assign(:site_footer_nav, Site.list_nav_items(:footer))
|> assign(:site_social_links, Site.list_social_links())
|> assign(:site_announcement_text, site_settings.announcement_text)
|> assign(:site_announcement_link, site_settings.announcement_link)
|> assign(:site_announcement_style, site_settings.announcement_style)
|> assign(:site_footer_about, site_settings.footer_about)
|> assign(:site_footer_copyright, site_settings.footer_copyright)
|> assign(:site_footer_show_newsletter, site_settings.show_newsletter)
|> assign(:site_state, SiteEditorState.load())
|> assign(:site_nav_pages, nav_pages)
|> assign(:editor_sheet_state, :open)
end
# Load pages suitable for navigation links
# Excludes non-linkable system pages like cart, checkout_success, error, etc.
defp load_nav_pages do
linkable_system_slugs = ~w(home about delivery privacy terms contact)
system_pages =
Pages.list_system_pages()
|> Enum.filter(&(&1.slug in linkable_system_slugs))
|> Enum.map(&%{slug: &1.slug, title: &1.title})
custom_pages =
Pages.list_custom_pages()
|> Enum.filter(& &1.published)
|> Enum.map(&%{slug: &1.slug, title: &1.title})
system_pages ++ custom_pages
end
# ── Unified save helpers ───────────────────────────────────────────
defp save_all_tabs(socket) do
@@ -1498,31 +1478,13 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp maybe_save_site(socket) do
if socket.assigns[:site_dirty] do
Site.put_announcement(
socket.assigns.site_announcement_text,
socket.assigns.site_announcement_link,
socket.assigns.site_announcement_style
)
state = socket.assigns[:site_state]
Site.put_footer_content(
socket.assigns.site_footer_about,
socket.assigns.site_footer_copyright,
socket.assigns.site_footer_show_newsletter
)
# Update original to match saved values
original = %{
announcement_text: socket.assigns.site_announcement_text,
announcement_link: socket.assigns.site_announcement_link,
announcement_style: socket.assigns.site_announcement_style,
footer_about: socket.assigns.site_footer_about,
footer_copyright: socket.assigns.site_footer_copyright,
show_newsletter: socket.assigns.site_footer_show_newsletter
}
if state && SiteEditorState.dirty?(state) do
saved_state = SiteEditorState.save(state)
socket
|> assign(:site_editor_original, original)
|> assign(:site_state, saved_state)
|> assign(:site_dirty, false)
else
socket
@@ -1577,25 +1539,16 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp maybe_revert_site(socket) do
if socket.assigns[:site_dirty] do
original = socket.assigns.site_editor_original
state = socket.assigns[:site_state]
if state && SiteEditorState.dirty?(state) do
reverted_state = SiteEditorState.revert(state)
socket
|> assign(:site_announcement_text, original.announcement_text)
|> assign(:site_announcement_link, original.announcement_link)
|> assign(:site_announcement_style, original.announcement_style)
|> assign(:site_footer_about, original.footer_about)
|> assign(:site_footer_copyright, original.footer_copyright)
|> assign(:site_footer_show_newsletter, original.show_newsletter)
|> assign(:site_state, reverted_state)
|> assign(:site_dirty, false)
else
socket
end
end
# ── Small helpers ───────────────────────────────────────────────────
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end

View File

@@ -126,15 +126,8 @@ defmodule BerrypodWeb.PageRenderer do
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)}
site_header_nav={Map.get(assigns, :site_header_nav, [])}
site_footer_nav={Map.get(assigns, :site_footer_nav, [])}
site_social_links={Map.get(assigns, :site_social_links, [])}
site_announcement_text={Map.get(assigns, :site_announcement_text, "")}
site_announcement_link={Map.get(assigns, :site_announcement_link, "")}
site_announcement_style={Map.get(assigns, :site_announcement_style, "info")}
site_footer_about={Map.get(assigns, :site_footer_about, "")}
site_footer_copyright={Map.get(assigns, :site_footer_copyright, "")}
site_footer_show_newsletter={Map.get(assigns, :site_footer_show_newsletter, true)}
site_state={Map.get(assigns, :site_state)}
site_nav_pages={Map.get(assigns, :site_nav_pages, [])}
/>
</.editor_sheet>
"""
@@ -172,15 +165,8 @@ defmodule BerrypodWeb.PageRenderer do
attr :settings_form, :map, default: nil
attr :settings_dirty, :boolean, default: false
attr :settings_save_status, :atom, default: :idle
attr :site_header_nav, :list, default: []
attr :site_footer_nav, :list, default: []
attr :site_social_links, :list, default: []
attr :site_announcement_text, :string, default: ""
attr :site_announcement_link, :string, default: ""
attr :site_announcement_style, :string, default: "info"
attr :site_footer_about, :string, default: ""
attr :site_footer_copyright, :string, default: ""
attr :site_footer_show_newsletter, :boolean, default: true
attr :site_state, :any, default: nil
attr :site_nav_pages, :list, default: []
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
~H"""
@@ -238,15 +224,8 @@ defmodule BerrypodWeb.PageRenderer do
defp editor_panel_content(%{editor_active_tab: :site} = assigns) do
~H"""
<BerrypodWeb.ShopComponents.SiteEditor.site_editor
site_header_nav={@site_header_nav}
site_footer_nav={@site_footer_nav}
site_social_links={@site_social_links}
site_announcement_text={@site_announcement_text}
site_announcement_link={@site_announcement_link}
site_announcement_style={@site_announcement_style}
site_footer_about={@site_footer_about}
site_footer_copyright={@site_footer_copyright}
site_footer_show_newsletter={@site_footer_show_newsletter}
site_state={@site_state}
site_nav_pages={@site_nav_pages}
/>
"""
end

View File

@@ -148,7 +148,6 @@ defmodule BerrypodWeb.Router do
post "/account/totp/cancel", AccountController, :cancel_totp_setup
get "/account/totp/complete", AccountController, :complete_totp_setup
post "/account/totp/dismiss-codes", AccountController, :clear_backup_codes
post "/navigation", NavigationController, :save
post "/providers", ProvidersController, :create
post "/providers/:id", ProvidersController, :update
@@ -176,7 +175,6 @@ defmodule BerrypodWeb.Router do
live "/pages/new", Admin.Pages.CustomForm, :new
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
live "/pages/:slug", Admin.Pages.Editor, :edit
live "/navigation", Admin.Navigation, :index
live "/media", Admin.Media, :index
live "/newsletter", Admin.Newsletter, :index
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new

View File

@@ -0,0 +1,399 @@
defmodule BerrypodWeb.SiteEditorState do
@moduledoc """
Encapsulates Site tab editor state with automatic dirty detection.
All Site tab state lives in a single struct. Comparing current state against
original state automatically determines if there are unsaved changes.
## Usage
# Load initial state
state = SiteEditorState.load()
# Update a field (returns {new_state, dirty?})
{state, dirty?} = SiteEditorState.put(state, :announcement_text, "New text")
# Update a list item
{state, dirty?} = SiteEditorState.update_social_link(state, id, %{url: "..."})
# Check if dirty
SiteEditorState.dirty?(state)
# Save to database
state = SiteEditorState.save(state)
# Revert to original
state = SiteEditorState.revert(state)
"""
alias Berrypod.Site
alias Berrypod.Site.{NavItem, SocialLink}
defstruct [
# Simple fields (Settings table)
:announcement_text,
:announcement_link,
:announcement_style,
:footer_about,
:footer_copyright,
:show_newsletter,
# List fields (separate tables)
:header_nav,
:footer_nav,
:social_links,
# Original state for dirty detection and revert
:_original
]
@type t :: %__MODULE__{
announcement_text: String.t(),
announcement_link: String.t(),
announcement_style: String.t(),
footer_about: String.t(),
footer_copyright: String.t(),
show_newsletter: boolean(),
header_nav: [NavItem.t()],
footer_nav: [NavItem.t()],
social_links: [SocialLink.t()],
_original: map() | nil
}
@doc """
Load Site editor state from database.
"""
@spec load() :: t()
def load do
settings = Site.get_settings()
header_nav = Site.list_nav_items(:header)
footer_nav = Site.list_nav_items(:footer)
social_links = Site.list_social_links()
state = %__MODULE__{
announcement_text: settings.announcement_text,
announcement_link: settings.announcement_link,
announcement_style: settings.announcement_style,
footer_about: settings.footer_about,
footer_copyright: settings.footer_copyright,
show_newsletter: settings.show_newsletter,
header_nav: header_nav,
footer_nav: footer_nav,
social_links: social_links
}
# Store snapshot for dirty detection
%{state | _original: snapshot(state)}
end
@doc """
Check if state has unsaved changes.
"""
@spec dirty?(t()) :: boolean()
def dirty?(%__MODULE__{_original: nil}), do: false
def dirty?(%__MODULE__{} = state) do
snapshot(state) != state._original
end
@doc """
Update a simple field. Returns updated state.
"""
@spec put(t(), atom(), any()) :: t()
def put(%__MODULE__{} = state, field, value) when is_atom(field) do
Map.put(state, field, value)
end
@doc """
Add a new social link. Returns updated state.
"""
@spec add_social_link(t()) :: t()
def add_social_link(%__MODULE__{social_links: links} = state) do
new_link = %SocialLink{
id: Ecto.UUID.generate(),
platform: "custom",
url: "",
position: length(links)
}
%{state | social_links: links ++ [new_link]}
end
@doc """
Update a social link by ID. Returns updated state.
"""
@spec update_social_link(t(), String.t(), map()) :: t()
def update_social_link(%__MODULE__{social_links: links} = state, id, attrs) do
updated_links =
Enum.map(links, fn link ->
if link.id == id, do: struct(link, attrs), else: link
end)
%{state | social_links: updated_links}
end
@doc """
Remove a social link by ID. Returns updated state.
"""
@spec remove_social_link(t(), String.t()) :: t()
def remove_social_link(%__MODULE__{social_links: links} = state, id) do
%{state | social_links: Enum.reject(links, &(&1.id == id))}
end
@doc """
Move a social link up or down. Returns updated state.
"""
@spec move_social_link(t(), String.t(), :up | :down) :: t()
def move_social_link(%__MODULE__{social_links: links} = state, id, direction) do
index = Enum.find_index(links, &(&1.id == id))
new_index =
case direction do
:up -> max(0, index - 1)
:down -> min(length(links) - 1, index + 1)
end
if index != new_index do
item = Enum.at(links, index)
other = Enum.at(links, new_index)
reordered =
links
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
%{state | social_links: reordered}
else
state
end
end
@doc """
Add a new nav item for the given location. Returns updated state.
"""
@spec add_nav_item(t(), String.t()) :: t()
def add_nav_item(%__MODULE__{} = state, location) do
items = get_nav_items(state, location)
new_item = %NavItem{
id: Ecto.UUID.generate(),
location: location,
label: "New link",
url: "",
position: length(items)
}
put_nav_items(state, location, items ++ [new_item])
end
@doc """
Update a nav item by ID. Returns updated state.
"""
@spec update_nav_item(t(), String.t(), String.t(), map()) :: t()
def update_nav_item(%__MODULE__{} = state, location, id, attrs) do
items = get_nav_items(state, location)
updated_items =
Enum.map(items, fn item ->
if item.id == id, do: struct(item, attrs), else: item
end)
put_nav_items(state, location, updated_items)
end
@doc """
Remove a nav item by ID. Returns updated state.
"""
@spec remove_nav_item(t(), String.t(), String.t()) :: t()
def remove_nav_item(%__MODULE__{} = state, location, id) do
items = get_nav_items(state, location)
put_nav_items(state, location, Enum.reject(items, &(&1.id == id)))
end
@doc """
Move a nav item up or down. Returns updated state.
"""
@spec move_nav_item(t(), String.t(), String.t(), :up | :down) :: t()
def move_nav_item(%__MODULE__{} = state, location, id, direction) do
items = get_nav_items(state, location)
index = Enum.find_index(items, &(&1.id == id))
new_index =
case direction do
:up -> max(0, index - 1)
:down -> min(length(items) - 1, index + 1)
end
if index != new_index do
item = Enum.at(items, index)
other = Enum.at(items, new_index)
reordered =
items
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
put_nav_items(state, location, reordered)
else
state
end
end
@doc """
Save all changes to the database. Returns updated state with new original.
"""
@spec save(t()) :: t()
def save(%__MODULE__{} = state) do
# Save simple settings
Site.put_announcement(
state.announcement_text,
state.announcement_link,
state.announcement_style
)
Site.put_footer_content(
state.footer_about,
state.footer_copyright,
state.show_newsletter
)
# Sync nav items and social links
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
social_links = sync_social_links(state.social_links, state._original.social_links)
# Update state with database-assigned IDs and new original
state = %{state | header_nav: header_nav, footer_nav: footer_nav, social_links: social_links}
%{state | _original: snapshot(state)}
end
@doc """
Revert to original state. Returns reset state.
"""
@spec revert(t()) :: t()
def revert(%__MODULE__{_original: original} = state) when not is_nil(original) do
%{
state
| announcement_text: original.announcement_text,
announcement_link: original.announcement_link,
announcement_style: original.announcement_style,
footer_about: original.footer_about,
footer_copyright: original.footer_copyright,
show_newsletter: original.show_newsletter,
header_nav: original.header_nav,
footer_nav: original.footer_nav,
social_links: original.social_links
}
end
# ── Private helpers ──────────────────────────────────────────────────
defp get_nav_items(%__MODULE__{header_nav: items}, "header"), do: items
defp get_nav_items(%__MODULE__{footer_nav: items}, "footer"), do: items
defp put_nav_items(state, "header", items), do: %{state | header_nav: items}
defp put_nav_items(state, "footer", items), do: %{state | footer_nav: items}
# Create a comparable snapshot of the state (excludes _original)
defp snapshot(%__MODULE__{} = state) do
%{
announcement_text: state.announcement_text,
announcement_link: state.announcement_link,
announcement_style: state.announcement_style,
footer_about: state.footer_about,
footer_copyright: state.footer_copyright,
show_newsletter: state.show_newsletter,
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
social_links: Enum.map(state.social_links, &social_link_snapshot/1)
}
end
# Only compare fields that matter for dirty detection
defp nav_item_snapshot(%NavItem{} = item) do
%{id: item.id, label: item.label, url: item.url}
end
defp social_link_snapshot(%SocialLink{} = link) do
%{id: link.id, platform: link.platform, url: link.url}
end
# Sync nav items - create new, update changed, delete removed
defp sync_nav_items(current, original) do
original_ids = MapSet.new(Enum.map(original, & &1.id))
current_ids = MapSet.new(Enum.map(current, & &1.id))
# Delete items that were removed
deleted_ids = MapSet.difference(original_ids, current_ids)
for id <- deleted_ids do
item = Enum.find(original, &(&1.id == id))
if item, do: Site.delete_nav_item(item)
end
# Create or update items
Enum.map(current, fn item ->
if MapSet.member?(original_ids, item.id) do
# Existing item - update if changed
orig = Enum.find(original, &(&1.id == item.id))
if orig.label != item.label or orig.url != item.url do
{:ok, updated} = Site.update_nav_item(orig, %{label: item.label, url: item.url})
updated
else
orig
end
else
# New item - create
{:ok, created} =
Site.create_nav_item(%{
location: item.location,
label: item.label,
url: item.url,
position: item.position
})
created
end
end)
end
# Sync social links - create new, update changed, delete removed
defp sync_social_links(current, original) do
original_ids = MapSet.new(Enum.map(original, & &1.id))
current_ids = MapSet.new(Enum.map(current, & &1.id))
# Delete links that were removed
deleted_ids = MapSet.difference(original_ids, current_ids)
for id <- deleted_ids do
link = Enum.find(original, &(&1.id == id))
if link, do: Site.delete_social_link(link)
end
# Create or update links
Enum.map(current, fn link ->
if MapSet.member?(original_ids, link.id) do
# Existing link - update if changed
orig = Enum.find(original, &(&1.id == link.id))
if orig.platform != link.platform or orig.url != link.url do
{:ok, updated} =
Site.update_social_link(orig, %{platform: link.platform, url: link.url})
updated
else
orig
end
else
# New link - create
{:ok, created} =
Site.create_social_link(%{
platform: link.platform,
url: link.url,
position: link.position
})
created
end
end)
end
end

View File

@@ -17,28 +17,6 @@ defmodule BerrypodWeb.ThemeHook do
alias Berrypod.{Products, Settings, Site, Media}
alias Berrypod.Theme.{CSSCache, CSSGenerator}
@default_header_nav [
%{"label" => "Home", "href" => "/", "slug" => "home"},
%{
"label" => "Shop",
"href" => "/collections/all",
"slug" => "collection",
"active_slugs" => ["collection", "pdp"]
},
%{"label" => "About", "href" => "/about", "slug" => "about"},
%{"label" => "Contact", "href" => "/contact", "slug" => "contact"}
]
@default_footer_nav [
%{"label" => "Delivery & returns", "href" => "/delivery", "slug" => "delivery"},
%{"label" => "Privacy policy", "href" => "/privacy", "slug" => "privacy"},
%{"label" => "Terms of service", "href" => "/terms", "slug" => "terms"},
%{"label" => "Contact", "href" => "/contact", "slug" => "contact"}
]
def default_header_nav, do: @default_header_nav
def default_footer_nav, do: @default_footer_nav
def on_mount(:mount_theme, _params, _session, socket) do
theme_settings = Settings.get_theme_settings()
@@ -92,13 +70,11 @@ defmodule BerrypodWeb.ThemeHook do
end
defp load_header_nav do
items = Site.nav_items_for_shop("header")
if items == [], do: @default_header_nav, else: add_active_slugs(items)
Site.nav_items_for_shop("header") |> add_active_slugs()
end
defp load_footer_nav do
items = Site.nav_items_for_shop("footer")
if items == [], do: @default_footer_nav, else: items
Site.nav_items_for_shop("footer")
end
# Add active_slugs for Shop nav item to highlight on collection and pdp pages