add data-driven navigation with admin editor
All checks were successful
deploy / deploy (push) Successful in 1m34s

Replace hardcoded header, footer and mobile nav with settings-driven
loops. Nav items stored as JSON via Settings, loaded in ThemeHook with
sensible defaults. New admin navigation editor at /admin/navigation
for add/remove/reorder/save/reset. Mobile bottom nav also driven from
header nav items with icon mapping by slug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-28 11:18:37 +00:00
parent 045be2ed7e
commit 3a243151af
11 changed files with 725 additions and 123 deletions

View File

@@ -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}
/>
</div>
"""
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
>
<ul>
<.mobile_nav_item
icon={:home}
label="Home"
page="home"
href="/"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:shop}
label="Shop"
page="collection"
href="/collections/all"
active_page={@active_page}
active_pages={["collection", "pdp"]}
mode={@mode}
/>
<.mobile_nav_item
icon={:about}
label="About"
page="about"
href="/about"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:contact}
label="Contact"
page="contact"
href="/contact"
:for={item <- @items}
icon={mobile_icon(item["slug"])}
label={item["label"]}
page={item["slug"] || ""}
href={item["href"]}
active_page={@active_page}
active_pages={item["active_slugs"]}
mode={@mode}
/>
</ul>
@@ -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"""
<svg
@@ -341,6 +335,24 @@ defmodule BerrypodWeb.ShopComponents.Layout do
"""
end
defp nav_icon(%{icon: :page} = assigns) do
~H"""
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
"""
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
</h4>
<ul class="footer-nav">
<%= if @mode == :preview do %>
<li>
<li :for={item <- @footer_nav_items}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="delivery"
phx-value-page={item["slug"]}
class="footer-link"
>
Delivery & returns
{item["label"]}
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="privacy"
class="footer-link"
>
Privacy policy
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="terms"
class="footer-link"
>
Terms of service
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="contact"
class="footer-link"
>
Contact
</a>
</li>
<% else %>
<li>
<.link
navigate="/delivery"
class="footer-link"
>
Delivery & returns
<% else %>
<.link navigate={item["href"]} class="footer-link">
{item["label"]}
</.link>
</li>
<li>
<.link
navigate="/privacy"
class="footer-link"
>
Privacy policy
</.link>
</li>
<li>
<.link
navigate="/terms"
class="footer-link"
>
Terms of service
</.link>
</li>
<li>
<.link
navigate="/contact"
class="footer-link"
>
Contact
</.link>
</li>
<% end %>
<% end %>
</li>
</ul>
</div>
</div>
@@ -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
</div>
<nav class="shop-nav">
<%= if @mode == :preview do %>
<.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} />
<.nav_item
label="Shop"
page="collection"
active_page={@active_page}
mode={:preview}
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" page="about" active_page={@active_page} mode={:preview} />
<.nav_item label="Contact" page="contact" active_page={@active_page} mode={:preview} />
<% else %>
<.nav_item label="Home" href="/" active_page={@active_page} page="home" />
<.nav_item
label="Shop"
href="/collections/all"
active_page={@active_page}
page="collection"
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" href="/about" active_page={@active_page} page="about" />
<.nav_item label="Contact" href="/contact" active_page={@active_page} page="contact" />
<% end %>
<.nav_item
:for={item <- @header_nav_items}
label={item["label"]}
href={item["href"]}
page={item["slug"] || ""}
active_page={@active_page}
active_pages={item["active_slugs"]}
mode={@mode}
/>
</nav>
<div class="shop-actions">