berrypod/lib/berrypod_web/components/shop_components/layout.ex
jamey 9528700862 rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:23:15 +00:00

940 lines
29 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

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

defmodule BerrypodWeb.ShopComponents.Layout do
use Phoenix.Component
import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content
@doc """
Renders the announcement bar.
The bar displays promotional messaging at the top of the page.
It uses CSS custom properties for theming.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `message` - Optional. The announcement message to display.
Defaults to "Free delivery on orders over £40".
## Examples
<.announcement_bar theme_settings={@theme_settings} />
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
"""
attr :theme_settings, :map, required: true
attr :message, :string, default: "Sample announcement e.g. free delivery, sales, or new drops"
def announcement_bar(assigns) do
~H"""
<div class="announcement-bar">
<p>{@message}</p>
</div>
"""
end
@doc """
Renders the skip link for keyboard navigation accessibility.
This is a standard accessibility pattern that allows keyboard users
to skip directly to the main content.
"""
def skip_link(assigns) do
~H"""
<a href="#main-content" class="skip-link">
Skip to main content
</a>
"""
end
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings 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)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 :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 :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: []
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}
logo_image={@logo_image}
header_image={@header_image}
active_page={@active_page}
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
/>
{render_slot(@inner_block)}
<.shop_footer
theme_settings={@theme_settings}
mode={@mode}
categories={assigns[:categories] || []}
/>
<.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
total={@cart_total}
cart_count={@cart_count}
mode={@mode}
open={@cart_drawer_open}
cart_status={@cart_status}
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
/>
<.search_modal
hint_text={~s(Try a search e.g. "mountain" or "notebook")}
search_query={@search_query}
search_results={@search_results}
search_open={@search_open}
/>
<.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
</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
def mobile_bottom_nav(assigns) do
~H"""
<nav
class="mobile-bottom-nav"
aria-label="Main navigation"
>
<ul>
<.mobile_nav_item
icon={:home}
label="Home"
page="home"
href="/"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:shop}
label="Shop"
page="collection"
href="/collections/all"
active_page={@active_page}
active_pages={["collection", "pdp"]}
mode={@mode}
/>
<.mobile_nav_item
icon={:about}
label="About"
page="about"
href="/about"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:contact}
label="Contact"
page="contact"
href="/contact"
active_page={@active_page}
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 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
@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 containing site_name.
* `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 :mode, :atom, default: :live
attr :categories, :list, default: []
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} />
<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">
<%= if @mode == :preview do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="delivery"
class="footer-link"
>
Delivery & returns
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="privacy"
class="footer-link"
>
Privacy policy
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="terms"
class="footer-link"
>
Terms of service
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="contact"
class="footer-link"
>
Contact
</a>
</li>
<% else %>
<li>
<.link
navigate="/delivery"
class="footer-link"
>
Delivery & returns
</.link>
</li>
<li>
<.link
navigate="/privacy"
class="footer-link"
>
Privacy policy
</.link>
</li>
<li>
<.link
navigate="/terms"
class="footer-link"
>
Terms of service
</.link>
</li>
<li>
<.link
navigate="/contact"
class="footer-link"
>
Contact
</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="footer-bottom">
<p class="footer-copyright">
© {@current_year} {@theme_settings.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 :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
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}
logo_image={@logo_image}
active_page={@active_page}
mode={@mode}
/>
</div>
<nav class="shop-nav">
<%= if @mode == :preview do %>
<.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} />
<.nav_item
label="Shop"
page="collection"
active_page={@active_page}
mode={:preview}
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" page="about" active_page={@active_page} mode={:preview} />
<.nav_item label="Contact" page="contact" active_page={@active_page} mode={:preview} />
<% else %>
<.nav_item label="Home" href="/" active_page={@active_page} page="home" />
<.nav_item
label="Shop"
href="/collections/all"
active_page={@active_page}
page="collection"
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" href="/about" active_page={@active_page} page="about" />
<.nav_item label="Contact" href="/contact" active_page={@active_page} page="contact" />
<% end %>
</nav>
<div class="shop-actions">
<.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>
<button
type="button"
class="header-icon-btn"
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
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>
</button>
<button
type="button"
class="header-icon-btn"
phx-click={open_cart_drawer_js()}
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>
</button>
</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 :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} 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} logo_image={@logo_image} />
</a>
<% else %>
<.link navigate="/" class="shop-logo-link">
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
</.link>
<% end %>
<% end %>
"""
end
attr :theme_settings, :map, 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">
{@theme_settings.site_name}
</span>
<% "logo-text" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% "logo-only" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% else %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% end %>
<% _ -> %>
<span class="shop-logo-text">
{@theme_settings.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 open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
end