diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index 36ff07a..e2a6b24 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -2152,4 +2152,71 @@
to { opacity: 1; }
}
+/* Navigation editor */
+
+.nav-editor-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ border: 1px solid var(--t-border-default);
+ border-radius: 0.375rem;
+ background: var(--t-surface-base);
+}
+
+.nav-editor-fields {
+ flex: 1;
+ display: flex;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.nav-editor-input {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.875rem;
+ padding: 0.375rem 0.5rem;
+}
+
+.nav-editor-actions {
+ display: flex;
+ gap: 0.125rem;
+ flex-shrink: 0;
+}
+
+.nav-editor-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ margin-top: 0.25rem;
+ min-width: 12rem;
+ background: var(--t-surface-base);
+ border: 1px solid var(--t-border-default);
+ border-radius: 0.375rem;
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+ overflow: hidden;
+}
+
+.nav-editor-dropdown-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ text-align: left;
+ cursor: pointer;
+ background: none;
+ border: none;
+ color: var(--t-text-primary);
+
+ @media (hover: hover) {
+ &:hover {
+ background: var(--t-surface-sunken);
+ }
+ }
+}
+
} /* @layer admin */
diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex
index 4051d7a..8d187d9 100644
--- a/lib/berrypod_web/components/layouts/admin.html.heex
+++ b/lib/berrypod_web/components/layouts/admin.html.heex
@@ -102,6 +102,14 @@
<.icon name="hero-document" class="size-5" /> Pages
+
diff --git a/lib/berrypod_web/controllers/error_html.ex b/lib/berrypod_web/controllers/error_html.ex
index 2977b7a..25f8b5c 100644
--- a/lib/berrypod_web/controllers/error_html.ex
+++ b/lib/berrypod_web/controllers/error_html.ex
@@ -94,6 +94,14 @@ 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)
+ )
# Load block data (e.g. products for featured_products block)
extra = safe_load(fn -> Pages.load_block_data(page.blocks, assigns) end) || %{}
@@ -159,4 +167,11 @@ defmodule BerrypodWeb.ErrorHTML do
_ -> nil
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.()
+ end
+ end
end
diff --git a/lib/berrypod_web/live/admin/navigation.ex b/lib/berrypod_web/live/admin/navigation.ex
new file mode 100644
index 0000000..fa27630
--- /dev/null
+++ b/lib/berrypod_web/live/admin/navigation.ex
@@ -0,0 +1,281 @@
+defmodule BerrypodWeb.Admin.Navigation do
+ use BerrypodWeb, :live_view
+
+ alias Berrypod.{Pages, Settings}
+ alias BerrypodWeb.ThemeHook
+
+ @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())
+ custom_pages = Pages.list_custom_pages()
+
+ {:ok,
+ socket
+ |> assign(:page_title, "Navigation")
+ |> assign(:header_items, header_items)
+ |> assign(:footer_items, footer_items)
+ |> assign(:custom_pages, custom_pages)
+ |> assign(:dirty, false)}
+ end
+
+ # ── Item manipulation ──────────────────────────────────────────
+
+ @impl true
+ def handle_event("update_item", params, socket) do
+ section = params["section"]
+ index = String.to_integer(params["index"])
+ field = params["field"]
+ value = params["value"]
+
+ items = get_items(socket, section)
+ item = Enum.at(items, index)
+
+ if item do
+ updated = Map.put(item, field, value)
+ items = List.replace_at(items, index, updated)
+ {:noreply, put_items(socket, section, items)}
+ 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)}
+ 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)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("add_item", %{"section" => section}, socket) do
+ items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
+ {:noreply, put_items(socket, section, items)}
+ end
+
+ def handle_event("add_page", %{"section" => section, "slug" => slug}, socket) do
+ page = Enum.find(socket.assigns.custom_pages, &(&1.slug == slug))
+
+ if page do
+ item = %{"label" => page.title, "href" => "/#{page.slug}", "slug" => page.slug}
+ items = get_items(socket, section) ++ [item]
+ {:noreply, put_items(socket, section, items)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ # ── Save / reset ───────────────────────────────────────────────
+
+ def handle_event("save", _params, socket) 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)
+ |> put_flash(:info, "Navigation saved")}
+ 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)}
+ end
+
+ # ── Render ─────────────────────────────────────────────────────
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.header>
+ Navigation
+ <:subtitle>Configure the links in your shop header and footer.
+
+
+
+ Unsaved changes
+
+
+
+ <.nav_section
+ title="Header navigation"
+ section="header"
+ items={@header_items}
+ custom_pages={@custom_pages}
+ />
+
+ <.nav_section
+ title="Footer navigation"
+ section="footer"
+ items={@footer_items}
+ custom_pages={@custom_pages}
+ />
+
+
+
+
+
+
+ """
+ end
+
+ defp nav_section(assigns) do
+ ~H"""
+
+
+ {@title}
+
+
+
+
+
+ No items yet.
+
+
+
+
+
+
+
+
+
+
+ """
+ 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 8c65a8f..ac4861a 100644
--- a/lib/berrypod_web/live/admin/pages/editor.ex
+++ b/lib/berrypod_web/live/admin/pages/editor.ex
@@ -425,6 +425,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())
|> preview_page_context(assigns.slug)
extra = Pages.load_block_data(page.blocks, preview)
diff --git a/lib/berrypod_web/live/admin/theme/index.ex b/lib/berrypod_web/live/admin/theme/index.ex
index 6af1512..5e7ab2b 100644
--- a/lib/berrypod_web/live/admin/theme/index.ex
+++ b/lib/berrypod_web/live/admin/theme/index.ex
@@ -399,7 +399,9 @@ defmodule BerrypodWeb.Admin.Theme.Index do
categories: assigns.preview_data.categories,
cart_items: PreviewData.cart_drawer_items(),
cart_count: 2,
- cart_subtotal: "£72.00"
+ cart_subtotal: "£72.00",
+ header_nav_items: BerrypodWeb.ThemeHook.default_header_nav(),
+ footer_nav_items: BerrypodWeb.ThemeHook.default_footer_nav()
})
end
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index 5b1cede..6ec8cb6 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -158,6 +158,7 @@ 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 "/redirects", Admin.Redirects, :index
end
diff --git a/lib/berrypod_web/theme_hook.ex b/lib/berrypod_web/theme_hook.ex
index 61ef3d4..f9f9da4 100644
--- a/lib/berrypod_web/theme_hook.ex
+++ b/lib/berrypod_web/theme_hook.ex
@@ -17,6 +17,28 @@ defmodule BerrypodWeb.ThemeHook do
alias Berrypod.{Products, Settings, 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()
@@ -43,6 +65,8 @@ defmodule BerrypodWeb.ThemeHook do
:is_admin,
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
)
+ |> assign(:header_nav_items, load_nav("header_nav", @default_header_nav))
+ |> assign(:footer_nav_items, load_nav("footer_nav", @default_footer_nav))
{:cont, socket}
end
@@ -64,4 +88,11 @@ defmodule BerrypodWeb.ThemeHook do
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
end
end
+
+ 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/test/berrypod_web/live/admin/navigation_test.exs b/test/berrypod_web/live/admin/navigation_test.exs
new file mode 100644
index 0000000..45b9b88
--- /dev/null
+++ b/test/berrypod_web/live/admin/navigation_test.exs
@@ -0,0 +1,163 @@
+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)
+ # New empty item has empty placeholder inputs
+ assert Regex.scan(~r/phx-value-section="header".*?phx-value-field="label"/, 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 "adding a custom page creates nav item", %{conn: conn} do
+ {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
+
+ {:ok, view, _html} = live(conn, ~p"/admin/navigation")
+
+ render_click(view, "add_page", %{"section" => "header", "slug" => "faq"})
+
+ html = render(view)
+ assert html =~ ~s(value="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")
+
+ assert render(view) =~ "Navigation 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")
+
+ 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/navigation_test.exs b/test/berrypod_web/live/shop/navigation_test.exs
new file mode 100644
index 0000000..97d029e
--- /dev/null
+++ b/test/berrypod_web/live/shop/navigation_test.exs
@@ -0,0 +1,91 @@
+defmodule BerrypodWeb.Shop.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_fixture()
+ {:ok, _} = Settings.set_site_live(true)
+ :ok
+ end
+
+ describe "header navigation" do
+ test "renders default items", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/")
+
+ assert html =~ "Home"
+ assert html =~ "Shop"
+ assert html =~ "About"
+ assert html =~ "Contact"
+ end
+
+ test "renders from saved settings", %{conn: conn} do
+ Settings.put_setting(
+ "header_nav",
+ [
+ %{"label" => "Blog", "href" => "/blog", "slug" => "blog"},
+ %{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
+ ],
+ "json"
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/")
+
+ assert html =~ "Blog"
+ assert html =~ "FAQ"
+ refute html =~ ~s(>Shop)
+ end
+ end
+
+ describe "footer navigation" do
+ test "renders default items", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/")
+
+ assert html =~ "Delivery & returns"
+ assert html =~ "Privacy policy"
+ assert html =~ "Terms of service"
+ end
+
+ test "renders from saved settings", %{conn: conn} do
+ Settings.put_setting(
+ "footer_nav",
+ [
+ %{"label" => "Returns", "href" => "/returns", "slug" => "returns"},
+ %{"label" => "Shipping", "href" => "/shipping", "slug" => "shipping"}
+ ],
+ "json"
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/")
+
+ assert html =~ "Returns"
+ assert html =~ "Shipping"
+ refute html =~ "Privacy policy"
+ end
+ end
+
+ describe "custom page in navigation" do
+ test "renders when added to header nav", %{conn: conn} do
+ {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
+
+ Settings.put_setting(
+ "header_nav",
+ [
+ %{"label" => "Home", "href" => "/", "slug" => "home"},
+ %{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
+ ],
+ "json"
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/")
+
+ assert html =~ ~s(href="/faq")
+ assert html =~ "FAQ"
+ end
+ end
+end