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

@ -158,7 +158,7 @@ Restructure the 3-tab editor panel for better discoverability. Replace Settings
| 1-4 | Data model + Site tab skeleton | 3h | done |
| 5-7 | Announcement bar (editable text, link, styles) | 1.5h | done |
| 8-9 | Social links editor (CRUD, reorder, platform detection, URL normalization) | 2h | done |
| 10-14 | Header & footer navigation editors | 3h | planned |
| 10-14 | Header & footer navigation editors | 3h | done |
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done |
| 17-18 | Move branding from Theme to Site | 1.5h | planned |
| 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | planned |
@ -166,6 +166,8 @@ Restructure the 3-tab editor panel for better discoverability. Replace Settings
Social links now support 40+ platforms (grouped by category), auto-detect platform from pasted URLs (including deep links like `tg://` and `spotify:`), normalize bare domains to https://, preserve custom protocols, and filter empty URLs from shop display.
Navigation editors support add/edit/delete/reorder for both header and footer nav items. Page picker dropdown allows linking to system pages (home, about, contact, etc.) or custom published pages. Items persist immediately on change.
### Unified editor session ([plan](docs/plans/unified-editor-session.md)) — Complete
Unified "optimistic preview with explicit save" across all editor tabs. Changes show immediately but only persist on Save. Free tab switching without warnings. Navigation blocking with Save/Discard/Cancel modal.

View File

@ -3295,21 +3295,47 @@
.site-editor-nav-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
gap: 0.5rem;
align-items: flex-start;
padding: 0.5rem;
background: var(--t-surface-sunken);
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.site-editor-nav-label {
.site-editor-nav-item-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.site-editor-nav-label-input {
font-weight: 500;
}
.site-editor-nav-url {
font-size: 0.75rem;
opacity: 0.6;
.site-editor-nav-url-row {
display: flex;
gap: 0.375rem;
}
.site-editor-nav-type {
width: 6rem;
flex-shrink: 0;
}
.site-editor-nav-page,
.site-editor-nav-url-input {
flex: 1;
min-width: 0;
}
.site-editor-nav-item-actions {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
padding-top: 0.25rem;
}
/* Social links editor item */

View File

@ -206,11 +206,11 @@ end
| 7 | Announcement bar: style variants | 6 | 15m | done |
| 8 | Social links: editor UI (list, add, remove, reorder) | 4 | 1.5h | done |
| 9 | Social links: read from database | 8 | 30m | done |
| 10 | Header nav: editor UI | 4 | 1h | planned |
| 11 | Header nav: page picker component | 10 | 30m | planned |
| 12 | Header nav: read from database | 11 | 30m | planned |
| 13 | Footer nav: editor UI | 10 | 30m | planned |
| 14 | Footer nav: read from database | 13 | 30m | planned |
| 10 | Header nav: editor UI | 4 | 1h | done |
| 11 | Header nav: page picker component | 10 | 30m | done |
| 12 | Header nav: read from database | 11 | 30m | done |
| 13 | Footer nav: editor UI | 10 | 30m | done |
| 14 | Footer nav: read from database | 13 | 30m | done |
| 15 | Footer content: about, copyright, newsletter toggle | 4 | 45m | done |
| 16 | Footer: read new fields | 15 | 30m | done |
| 17 | Move branding settings from Theme to Site | 4 | 1h | planned |
@ -250,10 +250,11 @@ The social links feature supports 40+ platforms grouped into 9 categories:
- Returns "custom" for unknown domains
**Data flow:**
- Database → `Site.list_social_links/0` → raw structs
- Database → `Site.list_social_links/0` → raw structs loaded into original and current state
- Shop display → `Site.social_links_for_shop/0` → filtered (no empty URLs), formatted maps
- Editor preview → `format_social_links_for_shop/1` in layout.ex → same filtering
- Social links card block now uses database data via `assigns[:social_links]`
- Changes preview immediately, persist on Save button via `sync_social_links/2`
**Files:**
- `lib/berrypod/site.ex` - Site context with social links CRUD
@ -262,6 +263,43 @@ The social links feature supports 40+ platforms grouped into 9 categories:
- `lib/berrypod_web/page_editor_hook.ex` - Event handlers
- `test/berrypod/site_test.exs` - 35 tests covering all functionality
### Completed: Navigation Editors (Tasks 10-14)
Header and footer navigation items are managed through a unified `nav_editor` component.
**Features:**
- Add/edit/delete/reorder nav items
- Label input with live preview
- Link type selector: Page (dropdown) or Custom URL (text input)
- Page picker shows linkable system pages (home, about, delivery, privacy, terms, contact) and published custom pages
- Move up/down buttons with disabled state at boundaries
- Changes preview immediately, persist on Save button
**Event handlers:**
- `site_add_nav_item` - Creates temporary item in local state
- `site_update_nav_item` - Updates label, URL, or page selection in local state based on `_target`
- `site_remove_nav_item` - Removes item from local state
- `site_move_nav_item` - Reorders items in local state
**Save/revert behavior:**
- All nav item and social link changes participate in the unified dirty state system
- `compute_site_dirty/1` compares current state against original state loaded at editor open
- On Save: `sync_nav_items/2` and `sync_social_links/2` diff current vs original and create/update/delete as needed
- On Discard: reverts to original state from `site_editor_original`
- Navigation warning modal appears when navigating with unsaved changes
**Data flow:**
- Database → `Site.list_nav_items/1` → raw structs loaded into original and current state
- Shop display → `Site.nav_items_for_shop/1` → formatted maps with computed href/slug
- Header/footer components already wired to use `@header_nav_items`/`@footer_nav_items`
**Files:**
- `lib/berrypod/site.ex` - NavItem CRUD and reorder functions
- `lib/berrypod/site/nav_item.ex` - Schema with location, label, url, page_id, position
- `lib/berrypod_web/components/shop_components/site_editor.ex` - `nav_editor` component
- `lib/berrypod_web/page_editor_hook.ex` - Event handlers, sync functions, dirty state
- `assets/css/admin/components.css` - Styles for nav editor
## UI Wireframes
### Site tab layout

View File

@ -6,8 +6,7 @@ defmodule Berrypod.Redirects.LinkScanner do
and by the admin UI to show where a broken URL is used.
"""
alias Berrypod.Pages
alias Berrypod.Settings
alias Berrypod.{Pages, Site}
# Block settings keys that contain URLs
@url_keys ~w(cta_href secondary_cta_href link_href href url)
@ -43,8 +42,8 @@ defmodule Berrypod.Redirects.LinkScanner do
Returns a list of `%{url: string, type: :internal | :external, sources: [source]}`.
"""
def scan_nav do
header_items = load_nav_items("header_nav")
footer_items = load_nav_items("footer_nav")
header_items = Site.nav_items_for_shop("header")
footer_items = Site.nav_items_for_shop("footer")
header_links =
Enum.flat_map(header_items, fn item ->
@ -147,13 +146,13 @@ defmodule Berrypod.Redirects.LinkScanner do
type: "nav_item",
id: "#{location}_nav",
label: "#{String.capitalize(location)} nav — #{label}",
edit_path: "/admin/navigation"
edit_path: "/?edit=site"
}
end
defp find_nav_sources(url) do
header_items = load_nav_items("header_nav")
footer_items = load_nav_items("footer_nav")
header_items = Site.nav_items_for_shop("header")
footer_items = Site.nav_items_for_shop("footer")
header =
header_items
@ -168,13 +167,6 @@ defmodule Berrypod.Redirects.LinkScanner do
header ++ footer
end
defp load_nav_items(key) do
case Settings.get_setting(key) do
items when is_list(items) -> items
_ -> []
end
end
defp group_by_url(links) do
links
|> Enum.group_by(& &1.url)

View File

@ -175,7 +175,7 @@ defmodule Berrypod.Site do
|> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end)
|> Enum.map(fn link ->
%{
platform: String.to_existing_atom(link.platform),
platform: String.to_atom(link.platform),
url: link.url,
label: platform_label(link.platform)
}
@ -352,7 +352,7 @@ defmodule Berrypod.Site do
}
end
# ── Seeding ────────────────────────────────────────────────────────
# ── Defaults ──────────────────────────────────────────────────────
@default_header_nav [
%{label: "Home", url: "/", position: 0},
@ -373,6 +373,41 @@ defmodule Berrypod.Site do
%{platform: "bluesky", url: "https://bsky.app", position: 1}
]
@doc """
Returns the default header navigation items in shop format.
Used for previews and error pages when database may be empty.
"""
def default_header_nav do
[
%{"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"}
]
end
@doc """
Returns the default footer navigation items in shop format.
Used for previews and error pages when database may be empty.
"""
def default_footer_nav do
[
%{"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"}
]
end
# ── Seeding ──────────────────────────────────────────────────────────
@doc """
Seeds default navigation items and social links if none exist.

View File

@ -24,9 +24,19 @@ defmodule Berrypod.Site.NavItem do
def changeset(nav_item, attrs) do
nav_item
|> cast(attrs, [:location, :label, :url, :page_id, :position])
|> validate_required([:location, :label, :url])
|> validate_required([:location, :label])
|> validate_inclusion(:location, @locations)
|> validate_length(:label, min: 1, max: 50)
|> foreign_key_constraint(:page_id)
|> default_url()
end
# Default to empty string if URL not provided (allows pending page selection)
defp default_url(changeset) do
if get_field(changeset, :url) == nil do
put_change(changeset, :url, "")
else
changeset
end
end
end

View File

@ -136,37 +136,63 @@ defmodule Berrypod.Site.SocialLink do
# Host-to-platform mapping for domain-based detection
@host_platforms %{
# Social
"instagram.com" => "instagram", "threads.net" => "threads", "tiktok.com" => "tiktok",
"facebook.com" => "facebook", "fb.com" => "facebook",
"twitter.com" => "twitter", "x.com" => "twitter",
"snapchat.com" => "snapchat", "linkedin.com" => "linkedin",
"instagram.com" => "instagram",
"threads.net" => "threads",
"tiktok.com" => "tiktok",
"facebook.com" => "facebook",
"fb.com" => "facebook",
"twitter.com" => "twitter",
"x.com" => "twitter",
"snapchat.com" => "snapchat",
"linkedin.com" => "linkedin",
# Video & streaming
"youtube.com" => "youtube", "youtu.be" => "youtube",
"twitch.tv" => "twitch", "vimeo.com" => "vimeo",
"kick.com" => "kick", "rumble.com" => "rumble",
"youtube.com" => "youtube",
"youtu.be" => "youtube",
"twitch.tv" => "twitch",
"vimeo.com" => "vimeo",
"kick.com" => "kick",
"rumble.com" => "rumble",
# Music & podcasts
"spotify.com" => "spotify", "open.spotify.com" => "spotify",
"soundcloud.com" => "soundcloud", "bandcamp.com" => "bandcamp",
"spotify.com" => "spotify",
"open.spotify.com" => "spotify",
"soundcloud.com" => "soundcloud",
"bandcamp.com" => "bandcamp",
"podcasts.apple.com" => "applepodcasts",
# Creative
"pinterest.com" => "pinterest", "pin.it" => "pinterest",
"behance.net" => "behance", "dribbble.com" => "dribbble",
"tumblr.com" => "tumblr", "medium.com" => "medium",
"pinterest.com" => "pinterest",
"pin.it" => "pinterest",
"behance.net" => "behance",
"dribbble.com" => "dribbble",
"tumblr.com" => "tumblr",
"medium.com" => "medium",
# Support & sales
"patreon.com" => "patreon", "ko-fi.com" => "kofi",
"etsy.com" => "etsy", "gumroad.com" => "gumroad", "substack.com" => "substack",
"patreon.com" => "patreon",
"ko-fi.com" => "kofi",
"etsy.com" => "etsy",
"gumroad.com" => "gumroad",
"substack.com" => "substack",
# Federated
"mastodon.social" => "mastodon", "pixelfed.social" => "pixelfed",
"bsky.app" => "bluesky", "lemmy.world" => "lemmy", "matrix.to" => "matrix",
"mastodon.social" => "mastodon",
"pixelfed.social" => "pixelfed",
"bsky.app" => "bluesky",
"lemmy.world" => "lemmy",
"matrix.to" => "matrix",
# Developer
"github.com" => "github", "gitlab.com" => "gitlab",
"codeberg.org" => "codeberg", "sr.ht" => "sourcehut",
"reddit.com" => "reddit", "old.reddit.com" => "reddit",
"github.com" => "github",
"gitlab.com" => "gitlab",
"codeberg.org" => "codeberg",
"sr.ht" => "sourcehut",
"reddit.com" => "reddit",
"old.reddit.com" => "reddit",
# Messaging
"discord.com" => "discord", "discord.gg" => "discord",
"t.me" => "telegram", "telegram.me" => "telegram",
"signal.me" => "signal", "signal.group" => "signal",
"wa.me" => "whatsapp", "whatsapp.com" => "whatsapp",
"discord.com" => "discord",
"discord.gg" => "discord",
"t.me" => "telegram",
"telegram.me" => "telegram",
"signal.me" => "signal",
"signal.group" => "signal",
"wa.me" => "whatsapp",
"whatsapp.com" => "whatsapp",
# Other
"linktr.ee" => "linktree"
}

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

View File

@ -137,17 +137,21 @@ defmodule Berrypod.Redirects.LinkScannerTest do
describe "scan_nav/0" do
test "extracts URLs from header and footer nav" do
Berrypod.Settings.put_setting(
"header_nav",
[%{"label" => "Shop", "href" => "/collections/all"}],
"json"
)
{:ok, _} =
Berrypod.Site.create_nav_item(%{
location: "header",
label: "Shop",
url: "/collections/all",
position: 0
})
Berrypod.Settings.put_setting(
"footer_nav",
[%{"label" => "Privacy", "href" => "/privacy"}],
"json"
)
{:ok, _} =
Berrypod.Site.create_nav_item(%{
location: "footer",
label: "Privacy",
url: "/privacy",
position: 0
})
links = LinkScanner.scan_nav()
urls = Enum.map(links, & &1.url)
@ -185,11 +189,13 @@ defmodule Berrypod.Redirects.LinkScannerTest do
end
test "finds nav items using a URL" do
Berrypod.Settings.put_setting(
"header_nav",
[%{"label" => "Shop", "href" => "/test-nav-link"}],
"json"
)
{:ok, _} =
Berrypod.Site.create_nav_item(%{
location: "header",
label: "Shop",
url: "/test-nav-link",
position: 0
})
sources = LinkScanner.find_sources("/test-nav-link")
@ -197,7 +203,7 @@ defmodule Berrypod.Redirects.LinkScannerTest do
[source] = sources
assert source.type == "nav_item"
assert source.label =~ "Header nav"
assert source.edit_path == "/admin/navigation"
assert source.edit_path == "/?edit=site"
end
test "returns empty for unused URL" do

View File

@ -6,16 +6,30 @@ defmodule Berrypod.SiteTest do
describe "social links CRUD" do
test "creates a social link" do
assert {:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test"})
assert {:ok, link} =
Site.create_social_link(%{
platform: "instagram",
url: "https://instagram.com/test"
})
assert link.platform == "instagram"
assert link.url == "https://instagram.com/test"
assert link.position == 0
end
test "lists social links ordered by position" do
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, _} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
{:ok, _} =
Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, _} =
Site.create_social_link(%{
platform: "instagram",
url: "https://instagram.com",
position: 0
})
{:ok, _} =
Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
links = Site.list_social_links()
assert length(links) == 3
@ -24,22 +38,35 @@ defmodule Berrypod.SiteTest do
test "updates a social link" do
{:ok, link} = Site.create_social_link(%{platform: "custom", url: ""})
{:ok, updated} = Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
{:ok, updated} =
Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
assert updated.url == "https://example.com"
assert updated.platform == "website"
end
test "deletes a social link" do
{:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
{:ok, link} =
Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
assert {:ok, _} = Site.delete_social_link(link)
assert Site.list_social_links() == []
end
test "reorders social links" do
{:ok, a} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, b} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, c} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
{:ok, a} =
Site.create_social_link(%{
platform: "instagram",
url: "https://instagram.com",
position: 0
})
{:ok, b} =
Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, c} =
Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
# Reorder: github, instagram, twitter
Site.reorder_social_links([c.id, a.id, b.id])
@ -51,7 +78,12 @@ defmodule Berrypod.SiteTest do
describe "social_links_for_shop/0" do
test "returns links formatted for shop components" do
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test", position: 0})
{:ok, _} =
Site.create_social_link(%{
platform: "instagram",
url: "https://instagram.com/test",
position: 0
})
[link] = Site.social_links_for_shop()
assert link.platform == :instagram
@ -60,7 +92,13 @@ defmodule Berrypod.SiteTest do
end
test "filters out links with empty URLs" do
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, _} =
Site.create_social_link(%{
platform: "instagram",
url: "https://instagram.com",
position: 0
})
{:ok, _} = Site.create_social_link(%{platform: "custom", url: "", position: 1})
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: nil, position: 2})
@ -89,7 +127,9 @@ defmodule Berrypod.SiteTest do
end
test "preserves app deep links" do
assert SocialLink.normalize_url("tg://resolve?domain=channel") == "tg://resolve?domain=channel"
assert SocialLink.normalize_url("tg://resolve?domain=channel") ==
"tg://resolve?domain=channel"
assert SocialLink.normalize_url("spotify:track:123") == "spotify:track:123"
assert SocialLink.normalize_url("rss://feed.example.com") == "rss://feed.example.com"
end
@ -177,10 +217,14 @@ defmodule Berrypod.SiteTest do
end
test "accepts URLs with any scheme" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
changeset =
SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
assert changeset.valid?
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
changeset =
SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
assert changeset.valid?
end
end

View File

@ -1,170 +0,0 @@
defmodule BerrypodWeb.Admin.NavigationTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.{Pages, Settings}
alias Berrypod.Pages.PageCache
setup do
PageCache.invalidate_all()
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/navigation")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "navigation editor" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders with header and footer sections", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
assert html =~ "Navigation"
assert html =~ "Header navigation"
assert html =~ "Footer navigation"
end
test "shows default header items", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
assert html =~ "Home"
assert html =~ "Shop"
assert html =~ "About"
assert html =~ "Contact"
end
test "shows default footer items", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
assert html =~ "Delivery &amp; returns"
assert html =~ "Privacy policy"
assert html =~ "Terms of service"
end
test "adding an item appends to list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
render_click(view, "add_item", %{"section" => "header"})
# Should have 5 items now (4 defaults + 1 new)
html = render(view)
# Count header item forms by their phx-value-section attribute
assert Regex.scan(~r/phx-value-section="header".*?phx-value-index/, html)
|> length() >= 5
end
test "removing an item removes from list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
render_click(view, "remove_item", %{"section" => "header", "index" => "0"})
html = render(view)
# "Home" should be gone (it was index 0)
refute html =~ ~s(value="Home")
# "Shop" should still be there
assert html =~ ~s(value="Shop")
end
test "moving item up reorders", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
render_click(view, "move_item", %{
"section" => "header",
"index" => "1",
"dir" => "up"
})
# Save and check order
render_click(view, "save")
items = Settings.get_setting("header_nav")
assert Enum.at(items, 0)["label"] == "Shop"
assert Enum.at(items, 1)["label"] == "Home"
end
test "custom pages appear in destination dropdown", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
# Custom page should appear in the dropdown options
assert html =~ "FAQ"
assert html =~ ~s(value="/faq")
end
test "save persists to settings", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
render_click(view, "remove_item", %{"section" => "footer", "index" => "0"})
render_click(view, "save")
# Inline feedback shows "Saved"
assert has_element?(view, ".admin-inline-feedback-saved", "Saved")
items = Settings.get_setting("footer_nav")
assert length(items) == 3
assert Enum.at(items, 0)["label"] == "Privacy policy"
end
test "reset defaults restores original items", %{conn: conn} do
# Save custom nav
Settings.put_setting("header_nav", [%{"label" => "Only", "href" => "/only"}], "json")
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
# Should show the custom item
assert render(view) =~ ~s(value="Only")
render_click(view, "reset_defaults")
html = render(view)
assert html =~ ~s(value="Home")
assert html =~ ~s(value="Shop")
refute html =~ ~s(value="Only")
end
test "dirty flag appears after changes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
refute has_element?(view, ".admin-badge-warning")
render_click(view, "add_item", %{"section" => "header"})
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
end
test "dirty flag clears after save", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
render_click(view, "add_item", %{"section" => "header"})
assert has_element?(view, ".admin-badge-warning")
# Fill in required fields for the new item before saving
render_change(view, "nav_item_change", %{
"section" => "header",
"index" => "4",
"label" => "Test",
"dest" => "/"
})
render_click(view, "save")
refute has_element?(view, ".admin-badge-warning")
end
test "save button disabled when not dirty", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
assert has_element?(view, "button[disabled]", "Save")
end
end
end

View File

@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.ContentTest do
setup do
user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
Berrypod.Site.seed_defaults()
:ok
end

View File

@ -8,6 +8,7 @@ defmodule BerrypodWeb.Shop.HomeTest do
setup do
user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
Berrypod.Site.seed_defaults()
conn = provider_connection_fixture()