add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s
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:
parent
5a5103bc42
commit
7c07805df8
@ -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.
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
399
lib/berrypod_web/site_editor_state.ex
Normal file
399
lib/berrypod_web/site_editor_state.ex
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 & 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user