diff --git a/PROGRESS.md b/PROGRESS.md index b85f15c..e6c4f9b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index be6e87f..99d8e01 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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 */ diff --git a/docs/plans/editor-reorganisation.md b/docs/plans/editor-reorganisation.md index 2719346..72df967 100644 --- a/docs/plans/editor-reorganisation.md +++ b/docs/plans/editor-reorganisation.md @@ -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 diff --git a/lib/berrypod/redirects/link_scanner.ex b/lib/berrypod/redirects/link_scanner.ex index 858d29d..a82eee8 100644 --- a/lib/berrypod/redirects/link_scanner.ex +++ b/lib/berrypod/redirects/link_scanner.ex @@ -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) diff --git a/lib/berrypod/site.ex b/lib/berrypod/site.ex index 80f24e2..ba5f895 100644 --- a/lib/berrypod/site.ex +++ b/lib/berrypod/site.ex @@ -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. diff --git a/lib/berrypod/site/nav_item.ex b/lib/berrypod/site/nav_item.ex index 716b783..66fa0fb 100644 --- a/lib/berrypod/site/nav_item.ex +++ b/lib/berrypod/site/nav_item.ex @@ -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 diff --git a/lib/berrypod/site/social_link.ex b/lib/berrypod/site/social_link.ex index 4c4b61e..c0039a1 100644 --- a/lib/berrypod/site/social_link.ex +++ b/lib/berrypod/site/social_link.ex @@ -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" } diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 1fec834..16a4755 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -118,14 +118,6 @@ <.icon name="hero-document" class="size-5" /> Pages -
  • - <.link - navigate={~p"/admin/navigation"} - class={admin_nav_active?(@current_path, "/admin/navigation")} - > - <.icon name="hero-bars-3" class="size-5" /> Navigation - -
  • <.link navigate={~p"/admin/media"} diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index aa4927f..2479796 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -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" diff --git a/lib/berrypod_web/components/shop_components/site_editor.ex b/lib/berrypod_web/components/shop_components/site_editor.ex index 820d07a..81f0165 100644 --- a/lib/berrypod_web/components/shop_components/site_editor.ex +++ b/lib/berrypod_web/components/shop_components/site_editor.ex @@ -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""" +
    +

    Loading site settings...

    +
    + """ + 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"""
    @@ -61,7 +68,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do <.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 title="Footer" icon="hero-document-text"> @@ -69,11 +81,16 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do <.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 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} />
    """ @@ -93,7 +110,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do # Use phx-hook to preserve open state across re-renders ~H""" -
    +
    <.icon :if={@icon} name={@icon} class="size-4" /> {@title} @@ -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"""
    -
      -
    • - {item.label} - {item.url} +
        +
      • +
        "update_nav_item"} + phx-value-id={item.id} + phx-value-location={@location} + > + +
        + + <%= if item.page_id != nil or (item.url == "" and @pages != []) do %> + + <% else %> + + <% end %> +
        +
        +
        + + + +
      -

      No navigation items

      -

      - Drag to reorder. Full editing coming in the next phase. +

      + No navigation items yet

      +
    """ end @@ -278,7 +407,11 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do class="site-editor-social-item" data-link-id={link.id} > -
    "update_social_link"} phx-value-id={link.id}> + "update_social_link"} + phx-value-id={link.id} + > 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 diff --git a/lib/berrypod_web/controllers/navigation_controller.ex b/lib/berrypod_web/controllers/navigation_controller.ex deleted file mode 100644 index a2ded46..0000000 --- a/lib/berrypod_web/controllers/navigation_controller.ex +++ /dev/null @@ -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 diff --git a/lib/berrypod_web/live/admin/navigation.ex b/lib/berrypod_web/live/admin/navigation.ex deleted file mode 100644 index e7f1871..0000000 --- a/lib/berrypod_web/live/admin/navigation.ex +++ /dev/null @@ -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""" -
    - """ - end - - defp nav_section(assigns) do - ~H""" -
    -

    - {@title} -

    - -
    - - <.nav_item - :for={{item, idx} <- Enum.with_index(@items)} - item={item} - idx={idx} - section={@section} - total={length(@items)} - link_options={@link_options} - /> -
    - -
    - No items yet. -
    - -
    - -
    -
    - """ - 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""" - - """ - 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 diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 46626c8..83a463e 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -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 diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index 65af237..417562b 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -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 diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index c90b328..3eeef5a 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -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, [])} /> """ @@ -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""" """ end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 501c621..cfd1109 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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 diff --git a/lib/berrypod_web/site_editor_state.ex b/lib/berrypod_web/site_editor_state.ex new file mode 100644 index 0000000..2751c08 --- /dev/null +++ b/lib/berrypod_web/site_editor_state.ex @@ -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 diff --git a/lib/berrypod_web/theme_hook.ex b/lib/berrypod_web/theme_hook.ex index a90d0ac..776b725 100644 --- a/lib/berrypod_web/theme_hook.ex +++ b/lib/berrypod_web/theme_hook.ex @@ -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 diff --git a/test/berrypod/redirects/link_scanner_test.exs b/test/berrypod/redirects/link_scanner_test.exs index 2ae4608..d90be51 100644 --- a/test/berrypod/redirects/link_scanner_test.exs +++ b/test/berrypod/redirects/link_scanner_test.exs @@ -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 diff --git a/test/berrypod/site_test.exs b/test/berrypod/site_test.exs index 918b45d..dedcac2 100644 --- a/test/berrypod/site_test.exs +++ b/test/berrypod/site_test.exs @@ -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 diff --git a/test/berrypod_web/live/admin/navigation_test.exs b/test/berrypod_web/live/admin/navigation_test.exs deleted file mode 100644 index 828d33a..0000000 --- a/test/berrypod_web/live/admin/navigation_test.exs +++ /dev/null @@ -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 diff --git a/test/berrypod_web/live/shop/content_test.exs b/test/berrypod_web/live/shop/content_test.exs index 41cb0a6..ce9e514 100644 --- a/test/berrypod_web/live/shop/content_test.exs +++ b/test/berrypod_web/live/shop/content_test.exs @@ -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 diff --git a/test/berrypod_web/live/shop/home_test.exs b/test/berrypod_web/live/shop/home_test.exs index d941552..8532464 100644 --- a/test/berrypod_web/live/shop/home_test.exs +++ b/test/berrypod_web/live/shop/home_test.exs @@ -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()