berrypod/lib/berrypod_web/components/shop_components/layout.ex
jamey 5b41f3fedf extract site_name and site_description from theme settings into standalone settings
site_name and site_description are shop identity, not theme concerns.
They now live in the Settings table as first-class settings with their
own assigns (@site_name, @site_description) piped through hooks and
plugs. The setup wizard writes site_name on account creation, and the
theme editor reads/writes via Settings.put_setting. Removed the
"configure your shop" checklist item since currency/country aren't
built yet. Also adds shop name field to setup wizard step 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:52:31 +00:00

952 lines
29 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule BerrypodWeb.ShopComponents.Layout do
use Phoenix.Component
import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content
@doc """
Renders the announcement bar.
The bar displays promotional messaging at the top of the page.
It uses CSS custom properties for theming.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `message` - Optional. The announcement message to display.
Defaults to "Free delivery on orders over £40".
## Examples
<.announcement_bar theme_settings={@theme_settings} />
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
"""
attr :theme_settings, :map, required: true
attr :message, :string, default: "Sample announcement e.g. free delivery, sales, or new drops"
def announcement_bar(assigns) do
~H"""
<div class="announcement-bar">
<p>{@message}</p>
</div>
"""
end
@doc """
Renders the skip link for keyboard navigation accessibility.
This is a standard accessibility pattern that allows keyboard users
to skip directly to the main content.
"""
def skip_link(assigns) do
~H"""
<a href="#main-content" class="skip-link">
Skip to main content
</a>
"""
end
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings site_name 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
header_nav_items footer_nav_items newsletter_enabled newsletter_state)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
Page templates can use this instead of listing every attr explicitly:
<.shop_layout {layout_assigns(assigns)} active_page="home">
...
</.shop_layout>
"""
def layout_assigns(assigns) do
Map.take(assigns, @layout_keys)
end
@doc """
Wraps page content in the standard shop shell: container, header, footer,
cart drawer, search modal, and mobile bottom nav.
Templates pass their unique `<main>` content as the inner block.
The `error_page` flag disables the CartPersist hook and mobile bottom nav.
"""
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :logo_image, :any, required: true
attr :header_image, :any, required: true
attr :mode, :atom, required: true
attr :cart_items, :list, required: true
attr :cart_count, :integer, required: true
attr :cart_subtotal, :string, required: true
attr :cart_total, :string, default: nil
attr :cart_drawer_open, :boolean, default: false
attr :cart_status, :string, default: nil
attr :active_page, :string, required: true
attr :error_page, :boolean, default: false
attr :is_admin, :boolean, default: false
attr :editing, :boolean, default: false
attr :editor_current_path, :string, default: nil
attr :editor_sidebar_open, :boolean, default: true
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
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: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
slot :inner_block, required: true
def shop_layout(assigns) do
~H"""
<div
id={unless @error_page, do: "shop-container"}
phx-hook={unless @error_page, do: "CartPersist"}
class="shop-container"
data-bottom-nav={!@error_page || nil}
>
<.skip_link />
<%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} />
<% end %>
<.shop_header
theme_settings={@theme_settings}
site_name={@site_name}
logo_image={@logo_image}
header_image={@header_image}
active_page={@active_page}
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
editing={@editing}
editor_current_path={@editor_current_path}
editor_sidebar_open={@editor_sidebar_open}
header_nav_items={@header_nav_items}
/>
{render_slot(@inner_block)}
<.shop_footer
theme_settings={@theme_settings}
site_name={@site_name}
mode={@mode}
categories={assigns[:categories] || []}
footer_nav_items={@footer_nav_items}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
total={@cart_total}
cart_count={@cart_count}
mode={@mode}
open={@cart_drawer_open}
cart_status={@cart_status}
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
/>
<.search_modal
hint_text={~s(Try a search e.g. "mountain" or "notebook")}
search_query={@search_query}
search_results={@search_results}
search_open={@search_open}
/>
<.mobile_bottom_nav
:if={!@error_page}
active_page={@active_page}
mode={@mode}
items={@header_nav_items}
/>
</div>
"""
end
@doc """
Renders a mobile bottom navigation bar.
This component provides thumb-friendly navigation for mobile devices,
following modern UX best practices. It's hidden on larger screens where
the standard header navigation is used.
## Attributes
* `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact").
* `mode` - Optional. Either `:live` (default) for real navigation or
`:preview` for theme preview mode with phx-click handlers.
* `cart_count` - Optional. Number of items in cart for badge display. Default: 0.
## Examples
<.mobile_bottom_nav active_page="home" />
<.mobile_bottom_nav active_page="collection" mode={:preview} />
"""
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"""
<nav
class="mobile-bottom-nav"
aria-label="Main navigation"
>
<ul>
<.mobile_nav_item
: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>
</nav>
"""
end
attr :icon, :atom, required: true
attr :label, :string, required: true
attr :page, :string, required: true
attr :href, :string, required: true
attr :active_page, :string, required: true
attr :active_pages, :list, default: nil
attr :mode, :atom, default: :live
defp mobile_nav_item(assigns) do
active_pages = assigns.active_pages || [assigns.page]
is_current = assigns.active_page in active_pages
assigns = assign(assigns, :is_current, is_current)
~H"""
<li>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@page}
class="mobile-nav-link"
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} />
<span>{@label}</span>
</a>
<% else %>
<.link
navigate={@href}
class="mobile-nav-link"
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} />
<span>{@label}</span>
</.link>
<% end %>
</li>
"""
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
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
"""
end
defp nav_icon(%{icon: :shop} = 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"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
"""
end
defp nav_icon(%{icon: :about} = 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"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
"""
end
defp nav_icon(%{icon: :contact} = 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="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
"""
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.
## Attributes
* `hint_text` - Hint text shown when no query is entered.
* `search_query` - Current search query string.
* `search_results` - List of Product structs matching the query.
"""
attr :hint_text, :string, default: nil
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
def search_modal(assigns) do
alias Berrypod.Cart
alias Berrypod.Products.{Product, ProductImage}
assigns =
assign(
assigns,
:results_with_images,
assigns.search_results
|> Enum.with_index()
|> Enum.map(fn {product, idx} ->
image = Product.primary_image(product)
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
end)
)
~H"""
<div
id="search-modal"
class="search-modal"
style={"display: #{if @search_open, do: "flex", else: "none"};"}
phx-hook="SearchModal"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
>
<div
class="search-panel"
onclick="event.stopPropagation()"
>
<div class="search-bar">
<svg
class="search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
id="search-input"
name="query"
class="search-input"
placeholder="Search products..."
value={@search_query}
phx-keyup="search"
phx-debounce="150"
autocomplete="off"
role="combobox"
aria-expanded={to_string(@search_results != [])}
aria-controls="search-results-list"
aria-autocomplete="list"
/>
<div
class="search-kbd"
aria-hidden="true"
>
<kbd>⌘</kbd><kbd>K</kbd>
</div>
<button
type="button"
class="search-close"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
aria-label="Close search"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="search-results">
<%= cond do %>
<% @search_results != [] -> %>
<ul id="search-results-list" role="listbox" aria-label="Search results">
<li
:for={item <- @results_with_images}
id={"search-result-#{item.idx}"}
role="option"
aria-selected="false"
>
<.link
navigate={"/products/#{item.product.slug || item.product.id}"}
class="search-result"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
>
<div
:if={item.image_url}
class="search-result-thumb"
>
<img
src={item.image_url}
alt={item.product.title}
loading="lazy"
/>
</div>
<div class="search-result-details">
<p class="search-result-title">
{item.product.title}
</p>
<p class="search-result-meta">
{item.product.category}
<span>
{Cart.format_price(item.product.cheapest_price)}
</span>
</p>
</div>
</.link>
</li>
</ul>
<% String.length(@search_query) >= 2 -> %>
<div class="search-hint">
<p>No products found for "{@search_query}"</p>
</div>
<% @hint_text != nil -> %>
<div class="search-hint">
<p>{@hint_text}</p>
</div>
<% true -> %>
<% end %>
</div>
</div>
</div>
"""
end
@doc """
Renders the shop footer with newsletter signup and links.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `mode` - Optional. Either `:live` (default) for real navigation or
`:preview` for theme preview mode with phx-click handlers.
## Examples
<.shop_footer theme_settings={@theme_settings} />
<.shop_footer theme_settings={@theme_settings} mode={:preview} />
"""
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :mode, :atom, default: :live
attr :categories, :list, default: []
attr :footer_nav_items, :list, default: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year)
~H"""
<footer class="shop-footer">
<div class="shop-footer-inner">
<div class="footer-grid">
<.newsletter_card
variant={:inline}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<div class="footer-links">
<div>
<h4 class="footer-heading">
Shop
</h4>
<ul class="footer-nav">
<%= if @mode == :preview do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="footer-link"
>
All products
</a>
</li>
<%= for category <- @categories do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="footer-link"
>
{category.name}
</a>
</li>
<% end %>
<% else %>
<li>
<.link
navigate="/collections/all"
class="footer-link"
>
All products
</.link>
</li>
<%= for category <- @categories do %>
<li>
<.link
navigate={"/collections/#{category.slug}"}
class="footer-link"
>
{category.name}
</.link>
</li>
<% end %>
<% end %>
</ul>
</div>
<div>
<h4 class="footer-heading">
Help
</h4>
<ul class="footer-nav">
<li :for={item <- @footer_nav_items}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={item["slug"]}
class="footer-link"
>
{item["label"]}
</a>
<% else %>
<.link navigate={item["href"]} class="footer-link">
{item["label"]}
</.link>
<% end %>
</li>
</ul>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="footer-bottom">
<p class="footer-copyright">
© {@current_year} {@site_name}
</p>
<.social_links />
</div>
</div>
</footer>
"""
end
@doc """
Renders the shop header with logo, navigation, and actions.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `logo_image` - Optional. The logo image struct (with id, is_svg fields).
* `header_image` - Optional. The header background image struct.
* `active_page` - Optional. Current page for nav highlighting.
* `mode` - Optional. Either `:live` (default) or `:preview`.
* `cart_count` - Optional. Number of items in cart. Defaults to 0.
## Examples
<.shop_header theme_settings={@theme_settings} />
<.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} />
"""
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :logo_image, :map, default: nil
attr :header_image, :map, default: nil
attr :active_page, :string, default: nil
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
attr :is_admin, :boolean, default: false
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"""
<header class="shop-header">
<%= if @theme_settings.header_background_enabled && @header_image do %>
<div style={header_background_style(@theme_settings, @header_image)} />
<% end %>
<div class="shop-logo">
<.logo_content
theme_settings={@theme_settings}
site_name={@site_name}
logo_image={@logo_image}
active_page={@active_page}
mode={@mode}
/>
</div>
<nav class="shop-nav">
<.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">
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
<.link
:if={@is_admin && !@editing && @editor_current_path}
patch={"#{@editor_current_path}?edit=true"}
class="header-icon-btn"
aria-label="Edit page"
>
<.edit_pencil_svg />
</.link>
<button
:if={@is_admin && @editing && !@editor_sidebar_open}
phx-click="editor_toggle_sidebar"
class="header-icon-btn"
aria-label="Show editor sidebar"
>
<.edit_pencil_svg />
</button>
<.link
:if={@is_admin}
href="/admin"
class="header-icon-btn"
aria-label="Admin"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</.link>
<a
href="/search"
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
class="header-icon-btn"
aria-label="Search"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</a>
<a
href="/cart"
phx-click={open_cart_drawer_js()}
class="header-icon-btn"
aria-label="Cart"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<%= if @cart_count > 0 do %>
<span class="cart-badge">
{@cart_count}
</span>
<% end %>
<span class="sr-only">Cart ({@cart_count})</span>
</a>
</div>
</header>
"""
end
defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do
clean_color = String.trim_leading(color, "#")
"/images/#{logo_image.id}/recolored/#{clean_color}"
end
defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp"
# Logo content that links to home, except when already on home page.
# This follows accessibility best practices - current page should not be a link.
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :logo_image, :map, default: nil
attr :active_page, :string, default: nil
attr :mode, :atom, default: :live
defp logo_content(assigns) do
is_home = assigns.active_page == "home"
assigns = assign(assigns, :is_home, is_home)
~H"""
<%= if @is_home do %>
<.logo_inner theme_settings={@theme_settings} site_name={@site_name} logo_image={@logo_image} />
<% else %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="home"
class="shop-logo-link"
>
<.logo_inner
theme_settings={@theme_settings}
site_name={@site_name}
logo_image={@logo_image}
/>
</a>
<% else %>
<.link navigate="/" class="shop-logo-link">
<.logo_inner
theme_settings={@theme_settings}
site_name={@site_name}
logo_image={@logo_image}
/>
</.link>
<% end %>
<% end %>
"""
end
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :logo_image, :map, default: nil
defp logo_inner(assigns) do
~H"""
<%= case @theme_settings.logo_mode do %>
<% "text-only" -> %>
<span class="shop-logo-text">
{@site_name}
</span>
<% "logo-text" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<span class="shop-logo-text">
{@site_name}
</span>
<% "logo-only" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% else %>
<span class="shop-logo-text">
{@site_name}
</span>
<% end %>
<% _ -> %>
<span class="shop-logo-text">
{@site_name}
</span>
<% end %>
"""
end
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"
end
# Navigation item that renders as a span (not a link) when on the current page.
# This follows accessibility best practices - current page should not be a link.
attr :label, :string, required: true
attr :page, :string, required: true
attr :active_page, :string, required: true
attr :href, :string, default: nil
attr :mode, :atom, default: :live
attr :active_pages, :list, default: nil
defp nav_item(assigns) do
# Allow matching multiple pages (e.g., "Shop" is active for both collection and pdp)
active_pages = assigns.active_pages || [assigns.page]
is_current = assigns.active_page in active_pages
assigns = assign(assigns, :is_current, is_current)
~H"""
<%= if @is_current do %>
<span class="nav-link" aria-current="page">
{@label}
</span>
<% else %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@page}
class="nav-link"
>
{@label}
</a>
<% else %>
<.link navigate={@href} class="nav-link">
{@label}
</.link>
<% end %>
<% end %>
"""
end
defp edit_pencil_svg(assigns) do
~H"""
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
"""
end
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
end