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 +
  • + <.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 ac15a3a..c168238 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -51,7 +51,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do @layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin search_query search_results search_open categories shipping_estimate - country_code available_countries editing editor_current_path editor_sidebar_open)a + country_code available_countries editing editor_current_path editor_sidebar_open + header_nav_items footer_nav_items)a @doc """ Extracts the assigns relevant to `shop_layout` from a full assigns map. @@ -95,6 +96,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :shipping_estimate, :integer, default: nil attr :country_code, :string, default: "GB" attr :available_countries, :list, default: [] + attr :header_nav_items, :list, default: [] + attr :footer_nav_items, :list, default: [] slot :inner_block, required: true @@ -123,6 +126,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do editing={@editing} editor_current_path={@editor_current_path} editor_sidebar_open={@editor_sidebar_open} + header_nav_items={@header_nav_items} /> {render_slot(@inner_block)} @@ -131,6 +135,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do theme_settings={@theme_settings} mode={@mode} categories={assigns[:categories] || []} + footer_nav_items={@footer_nav_items} /> <.cart_drawer @@ -153,7 +158,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do search_open={@search_open} /> - <.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} /> + <.mobile_bottom_nav + :if={!@error_page} + active_page={@active_page} + mode={@mode} + items={@header_nav_items} + /> """ end @@ -180,6 +190,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :active_page, :string, required: true attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 + attr :items, :list, default: [] def mobile_bottom_nav(assigns) do ~H""" @@ -189,36 +200,13 @@ defmodule BerrypodWeb.ShopComponents.Layout do > @@ -266,6 +254,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do """ end + defp mobile_icon("home"), do: :home + defp mobile_icon("collection"), do: :shop + defp mobile_icon("about"), do: :about + defp mobile_icon("contact"), do: :contact + defp mobile_icon(_), do: :page + defp nav_icon(%{icon: :home} = assigns) do ~H""" + + + + """ + end + @doc """ Renders the search modal overlay with live search results. @@ -509,6 +521,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :theme_settings, :map, required: true attr :mode, :atom, default: :live attr :categories, :list, default: [] + attr :footer_nav_items, :list, default: [] def shop_footer(assigns) do assigns = assign(assigns, :current_year, Date.utc_today().year) @@ -575,81 +588,22 @@ defmodule BerrypodWeb.ShopComponents.Layout do Help @@ -694,6 +648,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :editing, :boolean, default: false attr :editor_current_path, :string, default: nil attr :editor_sidebar_open, :boolean, default: true + attr :header_nav_items, :list, default: [] def shop_header(assigns) do ~H""" @@ -712,29 +667,15 @@ defmodule BerrypodWeb.ShopComponents.Layout do
    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