berrypod/lib/berrypod_web/components/shop_components/layout.ex
jamey 168b6ce76f
All checks were successful
deploy / deploy (push) Successful in 1m10s
implement unified on-site editor phases 1-2
Add theme editing to the existing PageEditorHook, enabling on-site
theme customisation alongside page editing. The editor panel now has
three tabs (Page, Theme, Settings) and can be collapsed while
keeping editing state intact.

- Add theme editing state and event handlers to PageEditorHook
- Add 3-tab UI with tab switching logic
- Add transparent overlay for click-outside dismiss
- Add mobile drag-to-resize with height persistence
- Fix animation replay on drag release (has-dragged class)
- Preserve panel height across LiveView re-renders
- Default to Page tab on editable pages, Theme otherwise
- Show unsaved changes indicator on FAB when panel collapsed
- Fix handle_event grouping warning in admin theme

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

1322 lines
41 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 generated_css 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 theme_editing editor_current_path editor_sidebar_open
editor_active_tab editor_sheet_state editor_dirty editor_save_status
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
attr :generated_css, :string, default: nil
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 themed"
data-bottom-nav={!@error_page || nil}
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<%!-- Live-updatable theme CSS (overrides static version in head) --%>
<%= if @generated_css do %>
{Phoenix.HTML.raw("<style id=\"theme-css-live\">#{@generated_css}</style>")}
<% end %>
<.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
# Show logo if enabled and image exists
show_logo = assigns.theme_settings.show_logo && assigns.logo_image
# Show site name if enabled, or as fallback when logo should show but image is missing
show_site_name =
assigns.theme_settings.show_site_name ||
(assigns.theme_settings.show_logo && !assigns.logo_image)
assigns =
assigns
|> assign(:show_logo, show_logo)
|> assign(:show_site_name, show_site_name)
~H"""
<%= if @show_logo do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<%= if @show_site_name do %>
<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/theme/settings 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 page edit mode is active.
* `theme_editing` - Whether theme edit mode is active.
* `editor_dirty` - Whether there are unsaved page changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
* `editor_active_tab` - Current tab (:page, :theme, :settings).
* `has_editable_page` - Whether the current page has editable blocks.
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :theme_editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page
attr :has_editable_page, :boolean, default: false
slot :inner_block
def editor_sheet(assigns) do
# Determine panel title based on active tab
title =
case assigns.editor_active_tab do
:page -> "Page"
:theme -> "Theme"
:settings -> "Settings"
end
# Any editing mode active
any_editing = assigns.editing || assigns.theme_editing
assigns =
assigns
|> assign(:title, title)
|> assign(:any_editing, any_editing)
~H"""
<%!-- Floating action button: always visible when panel is closed --%>
<button
:if={@editor_sheet_state == :collapsed}
type="button"
phx-click={if @any_editing, do: "editor_set_sheet_state", else: "editor_set_tab"}
phx-value-state={if @any_editing, do: "open", else: nil}
phx-value-tab={
if @any_editing, do: nil, else: if(@has_editable_page, do: "page", else: "theme")
}
class="editor-fab"
aria-label={if @any_editing, do: "Show editor", else: "Edit"}
>
<.edit_pencil_svg />
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
</button>
<%!-- Overlay to catch taps outside the panel --%>
<div
:if={@editor_sheet_state == :open}
class="editor-overlay"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
aria-hidden="true"
/>
<%!-- Editor panel: slides in/out --%>
<aside
id="editor-panel"
class="editor-panel"
role="region"
aria-label="Site editor"
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@any_editing)}
phx-hook="EditorSheet"
>
<%!-- Drag handle for mobile resizing --%>
<div class="editor-panel-drag-handle" data-drag-handle>
<div class="editor-panel-drag-handle-bar" />
</div>
<div class="editor-panel-header">
<div class="editor-panel-header-left">
<span class="editor-panel-title">{@title}</span>
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-panel-header-actions">
<button
:if={@editor_active_tab == :page && @editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
>
Saved ✓
</button>
<button
:if={@editor_active_tab == :page && @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>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
class="editor-panel-close"
aria-label="Close editor"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
width="20"
height="20"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<%!-- Tab bar --%>
<div class="editor-tabs" role="tablist">
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="page"
class={["editor-tab", @editor_active_tab == :page && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :page)}
disabled={!@has_editable_page}
title={
if @has_editable_page, do: "Edit page blocks", else: "This page has no editable blocks"
}
>
Page
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="theme"
class={["editor-tab", @editor_active_tab == :theme && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :theme)}
>
Theme
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="settings"
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :settings)}
>
Settings
</button>
</div>
<div class="editor-panel-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