All checks were successful
deploy / deploy (push) Successful in 1m26s
Disable checkout when Stripe isn't connected (cart drawer, cart page, and early guard in checkout controller to prevent orphaned orders). Show amber warning on order detail when email isn't configured. Fix pre-existing missing vertical spacing between page blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
954 lines
29 KiB
Elixir
954 lines
29 KiB
Elixir
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}
|
||
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}
|
||
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_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
|