berrypod/lib/berrypod_web/components/shop_components/layout.ex
jamey f4f036b84b
All checks were successful
deploy / deploy (push) Successful in 1m30s
replace admin rail with unified bottom sheet editor
- add editor sheet component anchored bottom (mobile) / right (desktop)
- admin cog moves to header, always visible for admins
- remove Done button from editor header, keep only Save
- add editor_at_defaults tracking to disable Reset when at defaults
- sheet collapses on click outside or Escape, stays in edit mode
- dirty indicator + beforeunload warning for unsaved changes
- keyboard shortcuts: Ctrl+Z undo, Ctrl+Shift+Z redo
- WCAG compliant: aria-expanded, live region, focus management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-07 09:30:07 +00:00

1234 lines
38 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 stripe_connected)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
attr :stripe_connected, :boolean, default: true
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}
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}
stripe_connected={@stripe_connected}
/>
<.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_nav_drawer
:if={!@error_page}
active_page={@active_page}
mode={@mode}
items={@header_nav_items}
categories={assigns[:categories] || []}
/>
</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 mobile navigation drawer.
A slide-out drawer containing the main navigation links for mobile users.
Triggered by the hamburger menu button in the header.
"""
attr :active_page, :string, required: true
attr :mode, :atom, default: :live
attr :items, :list, default: []
attr :categories, :list, default: []
def mobile_nav_drawer(assigns) do
~H"""
<div
id="mobile-nav-drawer"
class="mobile-nav-drawer"
phx-hook="MobileNavDrawer"
>
<div
class="mobile-nav-backdrop"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
</div>
<nav class="mobile-nav-panel" aria-label="Main navigation">
<div class="mobile-nav-header">
<span class="mobile-nav-title">Menu</span>
<button
type="button"
class="mobile-nav-close"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Close menu"
>
<svg
width="24"
height="24"
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>
<ul class="mobile-nav-links">
<li :for={item <- @items}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={item["slug"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
>
{item["label"]}
</a>
<% else %>
<.link
navigate={item["href"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
{item["label"]}
</.link>
<% end %>
</li>
</ul>
<%= if @categories != [] do %>
<div class="mobile-nav-section">
<span class="mobile-nav-section-title">Shop by category</span>
<ul class="mobile-nav-links">
<li :for={category <- @categories}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="mobile-nav-link"
>
{category.name}
</a>
<% else %>
<.link
navigate={"/collections/#{category.slug}"}
class="mobile-nav-link"
phx-click={
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
}
>
{category.name}
</.link>
<% end %>
</li>
</ul>
</div>
<% end %>
</nav>
</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 :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 %>
<%!-- Hamburger menu button (mobile only) --%>
<button
type="button"
class="header-hamburger"
phx-click={Phoenix.LiveView.JS.dispatch("open-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Open menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<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">
<%!-- Admin cog: always visible for admins, links to admin dashboard --%>
<.link
:if={@is_admin}
href="/admin"
class="header-icon-btn"
aria-label="Admin dashboard"
>
<.admin_cog_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
# ── Editor sheet ────────────────────────────────────────────────────
@doc """
Renders the unified editor sheet for page editing.
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
and two states on desktop (collapsed, open).
## Attributes
* `editing` - Whether edit mode is active.
* `editor_dirty` - Whether there are unsaved changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
slot :inner_block
def editor_sheet(assigns) do
~H"""
<aside
id="editor-sheet"
class="editor-sheet"
role="region"
aria-label="Page editor"
aria-expanded={to_string(@editor_sheet_state != :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@editing)}
phx-hook="EditorSheet"
>
<%!-- Header: content varies by state and editing mode --%>
<div class="editor-sheet-header">
<%= if @editor_sheet_state == :collapsed and not @editing do %>
<%!-- Not editing, collapsed: show Edit button to enter edit mode --%>
<button
type="button"
phx-click="editor_toggle_editing"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Edit page</span>
</button>
<% end %>
<%= if @editor_sheet_state == :collapsed and @editing do %>
<%!-- Editing but collapsed: show button to expand sheet (for previewing) --%>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="open"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Show editor</span>
</button>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
<% end %>
<%= if @editor_sheet_state != :collapsed do %>
<div class="editor-sheet-header-left">
<span class="editor-sheet-title">Page editor</span>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-sheet-header-actions">
<button
:if={@editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
>
Saved ✓
</button>
<button
:if={@editor_save_status != :saved}
type="button"
phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
disabled={!@editor_dirty}
>
Save
</button>
</div>
<% end %>
</div>
<%!-- Content area (hidden when collapsed) --%>
<div class="editor-sheet-content">
{render_slot(@inner_block)}
</div>
</aside>
<%!-- Live region for screen reader announcements --%>
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
"""
end
# ── Admin rail (deprecated) ────────────────────────────────────────
@doc """
Renders the admin rail with edit and admin icons.
This thin vertical bar appears on the left edge of the page for logged-in admins.
The edit button toggles the page editor, and the cog links to the admin dashboard.
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sidebar_open, :boolean, default: true
slot :editor_sidebar
slot :inner_block, required: true
def admin_rail(assigns) do
~H"""
<div
class="admin-rail-layout"
data-editing={to_string(@editing)}
data-sidebar-open={to_string(@editor_sidebar_open)}
>
<aside class="admin-rail" aria-label="Admin tools">
<button
type="button"
phx-click="editor_toggle_editing"
class={["admin-rail-btn", @editing && "admin-rail-btn-active"]}
aria-label={if @editing, do: "Close editor", else: "Edit page"}
aria-pressed={to_string(@editing)}
>
<.edit_pencil_svg />
<span
:if={@editing && @editor_dirty}
class="admin-rail-dirty-dot"
aria-label="Unsaved changes"
/>
</button>
<.link href="/admin" class="admin-rail-btn" aria-label="Admin dashboard">
<.admin_cog_svg />
</.link>
</aside>
<aside :if={@editing} class="admin-rail-sidebar" aria-label="Page editor">
{render_slot(@editor_sidebar)}
</aside>
<%!-- Backdrop to close sidebar on mobile --%>
<div
:if={@editing && @editor_sidebar_open}
class="admin-rail-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="admin-rail-content">
{render_slot(@inner_block)}
</div>
</div>
"""
end
def admin_cog_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="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>
"""
end
end