berrypod/lib/simpleshop_theme_web/components/shop_components.ex
Jamey Greenwood 0c15929c19 refactor: extract remaining page sections to ShopComponents
Add reusable components for all page sections:
- category_nav: Category circles for homepage navigation
- featured_products_section: Product grid with title and view-all CTA
- image_text_section: Two-column image + text layout
- collection_header: Page header with title and product count
- filter_bar: Category pills and sort dropdown
- content_body: Long-form content wrapper for about page
- contact_form: Contact form card
- order_tracking_card: Order tracking input card
- info_card: Bullet-point info card
- contact_info_card: Email contact card with icon
- social_links: Social media icon links
- cart_item: Cart item row with quantity controls
- order_summary: Order totals and checkout card
- breadcrumb: Navigation breadcrumb trail
- related_products_section: Related products grid

All preview pages now compose entirely of reusable components,
making them easier to maintain and enabling future section-based
theme customization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 15:14:25 +00:00

1889 lines
73 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 SimpleshopThemeWeb.ShopComponents do
@moduledoc """
Provides shop/storefront UI components.
These components are shared between the theme preview system and
the public storefront pages. They render using CSS custom properties
defined by the theme settings.
"""
use Phoenix.Component
@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: "Free delivery on orders over £40"
def announcement_bar(assigns) do
~H"""
<div
class="announcement-bar"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); text-align: center; padding: 0.5rem 1rem; font-size: var(--t-text-small);"
>
<p style="margin: 0;">{@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
@doc """
Renders the search modal overlay.
This is a modal dialog for searching products. Currently provides
the UI shell; search functionality will be added later.
## Attributes
* `hint_text` - Optional. Hint text shown below the search input.
Defaults to nil (no hint shown).
## Examples
<.search_modal />
<.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" />
"""
attr :hint_text, :string, default: nil
def search_modal(assigns) do
~H"""
<div
id="search-modal"
class="search-modal"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: none; align-items: flex-start; justify-content: center; padding-top: 10vh;"
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
>
<div
class="search-modal-content w-full max-w-xl mx-4"
style="background: var(--t-surface-raised); border-radius: var(--t-radius-card); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);"
phx-click-away={Phoenix.LiveView.JS.hide(to: "#search-modal")}
>
<div
class="flex items-center gap-3 p-4"
style="border-bottom: 1px solid var(--t-border-default);"
>
<svg class="w-5 h-5 flex-shrink-0" style="color: var(--t-text-tertiary);" 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"
class="flex-1 text-lg bg-transparent border-none outline-none"
style="font-family: var(--t-font-body); color: var(--t-text-primary);"
placeholder="Search products..."
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
/>
<button
type="button"
class="w-8 h-8 flex items-center justify-center transition-all"
style="color: var(--t-text-tertiary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
aria-label="Close search"
>
<svg class="w-5 h-5" 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>
<%= if @hint_text do %>
<div class="p-6" style="color: var(--t-text-tertiary);">
<p class="text-sm">{@hint_text}</p>
</div>
<% end %>
</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
def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year)
~H"""
<footer style="background-color: var(--t-surface-raised); border-top: 1px solid var(--t-border-default);">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 gap-12">
<!-- Newsletter -->
<div>
<h3 class="text-xl font-bold mb-2" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
Stay in touch
</h3>
<p class="mb-4 text-sm" style="color: var(--t-text-secondary);">
Get 10% off your first order and be the first to know about new designs.
</p>
<form class="flex flex-wrap gap-2">
<input
type="email"
placeholder="your@email.com"
class="flex-1 min-w-0 px-4 py-2 text-sm"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input); min-width: 150px;"
/>
<button
type="submit"
class="px-6 py-2 font-medium transition-all text-sm whitespace-nowrap"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
>
Subscribe
</button>
</form>
</div>
<!-- Links -->
<div class="grid grid-cols-2 gap-8">
<div>
<h4 class="font-semibold mb-4 text-sm" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
Shop
</h4>
<ul class="space-y-2 text-sm">
<%= if @mode == :preview do %>
<li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">All products</a></li>
<li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">New arrivals</a></li>
<li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Best sellers</a></li>
<% else %>
<li><a href="/products" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">All products</a></li>
<li><a href="/products?sort=newest" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">New arrivals</a></li>
<li><a href="/products?sort=popular" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Best sellers</a></li>
<% end %>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4 text-sm" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
Help
</h4>
<ul class="space-y-2 text-sm">
<%= if @mode == :preview do %>
<li><a href="#" phx-click="change_preview_page" phx-value-page="about" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Delivery</a></li>
<li><a href="#" phx-click="change_preview_page" phx-value-page="about" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Returns</a></li>
<li><a href="#" phx-click="change_preview_page" phx-value-page="contact" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Contact</a></li>
<% else %>
<li><a href="/delivery" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Delivery</a></li>
<li><a href="/returns" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Returns</a></li>
<li><a href="/contact" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Contact</a></li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4" style="border-top: 1px solid var(--t-border-subtle);">
<p class="text-xs" style="color: var(--t-text-tertiary);">
© {@current_year} {@theme_settings.site_name}
</p>
<div class="flex gap-2">
<a
href="#"
class="social-link w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
aria-label="Instagram"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
</svg>
</a>
<a
href="#"
class="social-link w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
aria-label="Pinterest"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 12c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4"></path>
<line x1="12" y1="16" x2="9" y2="21"></line>
</svg>
</a>
</div>
</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
def shop_header(assigns) do
~H"""
<header
class="shop-header"
style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center;"
>
<%= if @theme_settings.header_background_enabled && @header_image do %>
<div style={header_background_style(@theme_settings, @header_image)} />
<% end %>
<div class="shop-logo" style="display: flex; align-items: center; position: relative; z-index: 1;">
<%= case @theme_settings.logo_mode do %>
<% "text-only" -> %>
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: var(--t-text-xl); font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
<%= @theme_settings.site_name %>
</span>
<% "logo-text" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
style={"height: #{@theme_settings.logo_size}px; width: auto; margin-right: 0.5rem;"}
/>
<% end %>
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: var(--t-text-xl); font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
<%= @theme_settings.site_name %>
</span>
<% "logo-only" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
style={"height: #{@theme_settings.logo_size}px; width: auto;"}
/>
<% else %>
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: var(--t-text-xl); font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
<%= @theme_settings.site_name %>
</span>
<% end %>
<% _ -> %>
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: var(--t-text-xl); font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
<%= @theme_settings.site_name %>
</span>
<% end %>
</div>
<nav class="shop-nav hidden md:flex" style="gap: 1.5rem; position: relative; z-index: 1;">
<%= if @mode == :preview do %>
<a href="#" phx-click="change_preview_page" phx-value-page="home" aria-current={if @active_page == "home", do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">Home</a>
<a href="#" phx-click="change_preview_page" phx-value-page="collection" aria-current={if @active_page in ["collection", "pdp"], do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">Shop</a>
<a href="#" phx-click="change_preview_page" phx-value-page="about" aria-current={if @active_page == "about", do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">About</a>
<a href="#" phx-click="change_preview_page" phx-value-page="contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">Contact</a>
<% else %>
<a href="/" aria-current={if @active_page == "home", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
<a href="/products" aria-current={if @active_page in ["collection", "pdp"], do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
<a href="/about" aria-current={if @active_page == "about", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">About</a>
<a href="/contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
<% end %>
</nav>
<div class="shop-actions flex items-center gap-1" style="position: relative; z-index: 1;">
<button
type="button"
class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={Phoenix.LiveView.JS.show(to: "#search-modal", display: "flex") |> Phoenix.LiveView.JS.focus(to: "#search-input")}
aria-label="Search"
>
<svg class="w-5 h-5" 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 w-9 h-9 flex items-center justify-center transition-all relative"
style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay")}
aria-label="Cart"
>
<svg class="w-5 h-5" 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-count" style="position: absolute; top: -4px; right: -4px; background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); font-size: var(--t-text-caption); font-weight: 600; min-width: 18px; height: 18px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; padding: 0 4px;">{@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: "/images/#{logo_image.id}"
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/images/#{header_image.id}'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"
end
@doc """
Renders the cart drawer (floating sidebar).
The drawer slides in from the right when opened. It displays cart items
and checkout options.
## Attributes
* `cart_items` - List of cart items to display. Each item should have
`image`, `name`, `variant`, and `price` keys. Default: []
* `subtotal` - The subtotal to display. Default: nil (shows "£0.00")
* `mode` - Either `:live` (default) for real stores or `:preview` for theme editor.
In preview mode, "View basket" navigates via LiveView JS commands.
## Examples
<.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} />
<.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} />
"""
attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil
attr :mode, :atom, default: :live
def cart_drawer(assigns) do
assigns = assign_new(assigns, :display_subtotal, fn ->
assigns.subtotal || "£0.00"
end)
~H"""
<!-- Cart Drawer -->
<div
id="cart-drawer"
class="cart-drawer"
style="position: fixed; top: 0; right: -400px; width: 400px; max-width: 90vw; height: 100vh; background: var(--t-surface-raised); z-index: 1001; display: flex; flex-direction: column; transition: right 0.3s ease; box-shadow: -4px 0 20px rgba(0,0,0,0.15);"
>
<div class="cart-drawer-header" style="display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid var(--t-border-default);">
<h2 style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-large); color: var(--t-text-primary); margin: 0;">Your basket</h2>
<button
type="button"
class="cart-drawer-close"
style="background: none; border: none; padding: 0.5rem; cursor: pointer; color: var(--t-text-secondary);"
phx-click={Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")}
aria-label="Close cart"
>
<svg class="w-5 h-5" 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="cart-drawer-items" style="flex: 1; overflow-y: auto; padding: 1rem;">
<%= for item <- @cart_items do %>
<div class="cart-drawer-item" style="display: flex; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid var(--t-border-default);">
<div class="cart-drawer-item-image" style={"width: 60px; height: 60px; border-radius: var(--t-radius-card); background-size: cover; background-position: center; background-image: url('#{item.image}'); flex-shrink: 0;"}></div>
<div class="cart-drawer-item-details" style="flex: 1;">
<h3 style="font-family: var(--t-font-body); font-size: var(--t-text-small); font-weight: 500; color: var(--t-text-primary); margin: 0 0 2px;">
<%= item.name %>
</h3>
<p style="font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); margin: 0;">
<%= item.variant %>
</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4px;">
<p class="cart-drawer-item-price" style="color: var(--t-text-primary); font-weight: 500; font-size: var(--t-text-small); margin: 0;">
<%= item.price %>
</p>
<button type="button" style="background: none; border: none; padding: 0; cursor: pointer; font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); text-decoration: underline;">
Remove
</button>
</div>
</div>
</div>
<% end %>
</div>
<div class="cart-drawer-footer" style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);">
<div style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); margin-bottom: 0.5rem;">
<span>Delivery</span>
<span>Calculated at checkout</span>
</div>
<div class="cart-drawer-total" style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-base); font-weight: 600; color: var(--t-text-primary); margin-bottom: 1rem;">
<span>Subtotal</span>
<span><%= @display_subtotal %></span>
</div>
<button
type="submit"
class="cart-drawer-checkout w-full mb-2"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition-all; background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
>
Checkout
</button>
<%= if @mode == :preview do %>
<a
href="#"
phx-click={Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay") |> Phoenix.LiveView.JS.push("change_preview_page", value: %{page: "cart"})}
class="cart-drawer-link"
style="display: block; text-align: center; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); text-decoration: underline; cursor: pointer;"
>
View basket
</a>
<% else %>
<a
href="/cart"
phx-click={Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")}
class="cart-drawer-link"
style="display: block; text-align: center; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); text-decoration: underline; cursor: pointer;"
>
View basket
</a>
<% end %>
</div>
</div>
<!-- Cart Drawer Overlay -->
<div
id="cart-drawer-overlay"
class="cart-drawer-overlay"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 999; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease;"
phx-click={Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")}
>
</div>
<style>
.cart-drawer.open {
right: 0 !important;
}
.cart-drawer-overlay.open {
opacity: 1 !important;
visibility: visible !important;
}
</style>
"""
end
@doc """
Renders a product card with configurable variants.
## Attributes
* `product` - Required. The product map with `name`, `image_url`, `price`, etc.
* `theme_settings` - Required. The theme settings map.
* `mode` - Either `:live` (default) or `:preview`.
* `variant` - The visual variant:
- `:default` - Collection page style with border, category, full details
- `:featured` - Home page style with hover lift, no border
- `:compact` - PDP related products with aspect-square, minimal info
- `:minimal` - Error 404 style, smallest, not clickable
* `show_category` - Show category label. Defaults based on variant.
* `show_badges` - Show product badges. Defaults based on variant.
* `show_delivery_text` - Show "Free delivery" text. Defaults based on variant.
* `clickable` - Whether the card navigates. Defaults based on variant.
## Examples
<.product_card product={product} theme_settings={@theme_settings} />
<.product_card product={product} theme_settings={@theme_settings} variant={:featured} mode={:preview} />
"""
attr :product, :map, required: true
attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live
attr :variant, :atom, default: :default
attr :show_category, :boolean, default: nil
attr :show_badges, :boolean, default: nil
attr :show_delivery_text, :boolean, default: nil
attr :clickable, :boolean, default: nil
def product_card(assigns) do
# Apply variant defaults for nil values
defaults = variant_defaults(assigns.variant)
assigns =
assigns
|> assign_new(:show_category_resolved, fn ->
if assigns.show_category == nil, do: defaults.show_category, else: assigns.show_category
end)
|> assign_new(:show_badges_resolved, fn ->
if assigns.show_badges == nil, do: defaults.show_badges, else: assigns.show_badges
end)
|> assign_new(:show_delivery_text_resolved, fn ->
if assigns.show_delivery_text == nil,
do: defaults.show_delivery_text,
else: assigns.show_delivery_text
end)
|> assign_new(:clickable_resolved, fn ->
if assigns.clickable == nil, do: defaults.clickable, else: assigns.clickable
end)
~H"""
<%= if @clickable_resolved do %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="pdp"
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% else %>
<a
href={"/products/#{@product[:slug] || @product[:id]}"}
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% end %>
<% else %>
<div
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</div>
<% end %>
"""
end
attr :product, :map, required: true
attr :theme_settings, :map, required: true
attr :variant, :atom, required: true
attr :show_category, :boolean, required: true
attr :show_badges, :boolean, required: true
attr :show_delivery_text, :boolean, required: true
defp product_card_inner(assigns) do
~H"""
<div class={image_container_classes(@variant)}>
<%= if @show_badges do %>
<.product_badge product={@product} />
<% end %>
<img
src={@product.image_url}
alt={@product.name}
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/>
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
<img
src={@product.hover_image_url}
alt={@product.name}
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div class={content_padding_class(@variant)}>
<%= if @show_category && @product[:category] do %>
<p class="text-xs mb-1" style="color: var(--t-text-tertiary);">
<%= @product.category %>
</p>
<% end %>
<h3 class={title_classes(@variant)} style={title_style(@variant)}>
<%= @product.name %>
</h3>
<%= if @theme_settings.show_prices do %>
<.product_price product={@product} variant={@variant} />
<% end %>
<%= if @show_delivery_text do %>
<p class="text-xs mt-1" style="color: var(--t-text-tertiary);">
Free delivery over £40
</p>
<% end %>
</div>
"""
end
attr :product, :map, required: true
defp product_badge(assigns) do
~H"""
<%= cond do %>
<% Map.get(@product, :in_stock, true) == false -> %>
<span class="product-badge badge-sold-out">Sold out</span>
<% @product[:is_new] -> %>
<span class="product-badge badge-new">New</span>
<% @product[:on_sale] -> %>
<span class="product-badge badge-sale">Sale</span>
<% true -> %>
<% end %>
"""
end
attr :product, :map, required: true
attr :variant, :atom, required: true
defp product_price(assigns) do
~H"""
<%= case @variant do %>
<% :default -> %>
<div>
<%= if @product.on_sale do %>
<span class="text-lg font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
£<%= @product.price / 100 %>
</span>
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
£<%= @product.compare_at_price / 100 %>
</span>
<% else %>
<span class="text-lg font-bold" style="color: var(--t-text-primary);">
£<%= @product.price / 100 %>
</span>
<% end %>
</div>
<% :featured -> %>
<p class="text-sm" style="color: var(--t-text-secondary);">
<%= if @product.on_sale do %>
<span class="line-through mr-1" style="color: var(--t-text-tertiary);">£<%= @product.compare_at_price / 100 %></span>
<% end %>
£<%= @product.price / 100 %>
</p>
<% :compact -> %>
<p class="font-bold" style="color: var(--t-text-primary);">
£<%= @product.price / 100 %>
</p>
<% :minimal -> %>
<p class="text-xs" style="color: var(--t-text-secondary);">
£<%= @product.price / 100 %>
</p>
<% end %>
"""
end
defp variant_defaults(:default),
do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true}
defp variant_defaults(:featured),
do: %{show_category: false, show_badges: true, show_delivery_text: true, clickable: true}
defp variant_defaults(:compact),
do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: true}
defp variant_defaults(:minimal),
do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: false}
defp card_classes(:default), do: "product-card group overflow-hidden transition-all"
defp card_classes(:featured), do: "product-card group overflow-hidden transition-all hover:-translate-y-1"
defp card_classes(:compact), do: "product-card group overflow-hidden cursor-pointer"
defp card_classes(:minimal), do: "product-card group overflow-hidden"
defp card_style(:default),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;"
defp card_style(:featured),
do: "background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;"
defp card_style(:compact),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
defp card_style(:minimal),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);"
defp image_container_classes(:compact),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(:minimal),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(_),
do: "product-image-container bg-gray-200 overflow-hidden relative"
defp content_padding_class(:compact), do: "p-3"
defp content_padding_class(:minimal), do: "p-2"
defp content_padding_class(_), do: ""
defp title_classes(:default), do: "font-semibold mb-2"
defp title_classes(:featured), do: "text-sm font-medium mb-1"
defp title_classes(:compact), do: "font-semibold text-sm mb-1"
defp title_classes(:minimal), do: "text-xs font-semibold truncate"
defp title_style(:default), do: "font-family: var(--t-font-heading); color: var(--t-text-primary);"
defp title_style(:featured), do: "color: var(--t-text-primary);"
defp title_style(:compact), do: "font-family: var(--t-font-heading); color: var(--t-text-primary);"
defp title_style(:minimal), do: "color: var(--t-text-primary);"
@doc """
Renders a responsive product grid container.
This component wraps product cards in a responsive grid layout. It supports
theme-based column settings or fixed column layouts for specific use cases.
## Attributes
* `theme_settings` - Optional. When provided, uses `grid_columns` setting for lg breakpoint.
* `columns` - Optional. Fixed column count for lg breakpoint (overrides theme_settings).
Use `:fixed_4` for a fixed 2/4 column layout (error page, related products).
* `gap` - Optional. Gap size. Defaults to standard gap.
* `class` - Optional. Additional CSS classes to add.
## Slots
* `inner_block` - Required. The product cards to render inside the grid.
## Examples
<.product_grid theme_settings={@theme_settings}>
<%= for product <- @products do %>
<.product_card product={product} theme_settings={@theme_settings} />
<% end %>
</.product_grid>
<.product_grid columns={:fixed_4} gap="gap-6">
...
</.product_grid>
"""
attr :theme_settings, :map, default: nil
attr :columns, :atom, default: nil
attr :gap, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
def product_grid(assigns) do
~H"""
<div class={grid_classes(@theme_settings, @columns, @gap, @class)}>
<%= render_slot(@inner_block) %>
</div>
"""
end
defp grid_classes(theme_settings, columns, gap, extra_class) do
base = "product-grid grid"
cols =
cond do
columns == :fixed_4 ->
"grid-cols-2 md:grid-cols-4"
theme_settings != nil ->
responsive_cols = "grid-cols-1 sm:grid-cols-2"
lg_cols =
case theme_settings.grid_columns do
"2" -> "lg:grid-cols-2"
"3" -> "lg:grid-cols-3"
"4" -> "lg:grid-cols-4"
_ -> "lg:grid-cols-3"
end
"#{responsive_cols} #{lg_cols}"
true ->
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
end
gap_class = gap || ""
[base, cols, gap_class, extra_class]
|> Enum.reject(&is_nil/1)
|> Enum.reject(&(&1 == ""))
|> Enum.join(" ")
end
@doc """
Renders a centered hero section with title, description, and optional CTAs.
## Attributes
* `title` - Required. The main heading text.
* `description` - Required. The description paragraph text.
* `variant` - The visual variant:
- `:default` - Standard hero with section wrapper and padding
- `:page` - Page header style, larger title, more spacing, no section wrapper
- `:error` - Error page with pre-title (404), two buttons
* `background` - Background style. Either `:base` (default) or `:sunken`.
* `pre_title` - Optional. Text shown above title (e.g., "404" for error pages).
* `cta_text` - Optional. Text for the primary CTA button.
* `cta_page` - Optional. Page to navigate to on click (for preview mode).
* `cta_href` - Optional. URL for live mode navigation.
* `secondary_cta_text` - Optional. Text for secondary button (error variant).
* `secondary_cta_page` - Optional. Page for secondary button (preview mode).
* `secondary_cta_href` - Optional. URL for secondary button (live mode).
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.hero_section
title="Original designs, printed on demand"
description="From art prints to apparel..."
cta_text="Shop the collection"
cta_page="collection"
mode={:preview}
/>
<.hero_section
variant={:page}
title="Contact Us"
description="Questions about your order?"
/>
<.hero_section
variant={:error}
pre_title="404"
title="Page Not Found"
description="Sorry, we couldn't find the page..."
cta_text="Go to Homepage"
secondary_cta_text="Browse Products"
mode={:preview}
/>
"""
attr :title, :string, required: true
attr :description, :string, required: true
attr :variant, :atom, default: :default
attr :background, :atom, default: :base
attr :pre_title, :string, default: nil
attr :cta_text, :string, default: nil
attr :cta_page, :string, default: nil
attr :cta_href, :string, default: nil
attr :secondary_cta_text, :string, default: nil
attr :secondary_cta_page, :string, default: nil
attr :secondary_cta_href, :string, default: nil
attr :mode, :atom, default: :live
def hero_section(assigns) do
~H"""
<%= case @variant do %>
<% :default -> %>
<section
class="text-center"
style={"padding: var(--space-2xl) var(--space-lg); background-color: var(--t-surface-#{@background});"}
>
<h1
class="text-3xl md:text-4xl mb-4"
style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);"
>
<%= @title %>
</h1>
<p class="text-lg max-w-lg mx-auto mb-8" style="color: var(--t-text-secondary); line-height: 1.6;">
<%= @description %>
</p>
<.hero_cta
:if={@cta_text}
text={@cta_text}
page={@cta_page}
href={@cta_href}
mode={@mode}
variant={:primary}
/>
</section>
<% :page -> %>
<div class="text-center">
<h1
class="text-4xl md:text-5xl font-bold mb-6"
style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);"
>
<%= @title %>
</h1>
<p class="text-lg mb-12 max-w-2xl mx-auto" style="color: var(--t-text-secondary);">
<%= @description %>
</p>
</div>
<% :error -> %>
<div class="text-center">
<%= if @pre_title do %>
<h1
class="text-8xl md:text-9xl font-bold mb-4"
style="font-family: var(--t-font-heading); color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); font-weight: var(--t-heading-weight);"
>
<%= @pre_title %>
</h1>
<% end %>
<h2
class="text-3xl md:text-4xl font-bold mb-6"
style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);"
>
<%= @title %>
</h2>
<p class="text-lg mb-8 max-w-md mx-auto" style="color: var(--t-text-secondary);">
<%= @description %>
</p>
<%= if @cta_text || @secondary_cta_text do %>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<.hero_cta
:if={@cta_text}
text={@cta_text}
page={@cta_page}
href={@cta_href}
mode={@mode}
variant={:primary}
/>
<.hero_cta
:if={@secondary_cta_text}
text={@secondary_cta_text}
page={@secondary_cta_page}
href={@secondary_cta_href}
mode={@mode}
variant={:secondary}
/>
</div>
<% end %>
</div>
<% end %>
"""
end
attr :text, :string, required: true
attr :page, :string, default: nil
attr :href, :string, default: nil
attr :mode, :atom, required: true
attr :variant, :atom, required: true
defp hero_cta(assigns) do
~H"""
<%= if @mode == :preview do %>
<button
phx-click="change_preview_page"
phx-value-page={@page}
class={hero_cta_classes(@variant)}
style={hero_cta_style(@variant)}
>
<%= @text %>
</button>
<% else %>
<a
href={@href || "/"}
class={["inline-block", hero_cta_classes(@variant)]}
style={hero_cta_style(@variant) <> " text-decoration: none;"}
>
<%= @text %>
</a>
<% end %>
"""
end
defp hero_cta_classes(:primary), do: "px-8 py-3 font-semibold transition-all"
defp hero_cta_classes(:secondary), do: "px-8 py-3 font-semibold transition-all"
defp hero_cta_style(:primary) do
"background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer;"
end
defp hero_cta_style(:secondary) do
"border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
end
@doc """
Renders a row of category circles for navigation.
## Attributes
* `categories` - Required. List of category maps with `name` and `image_url`.
* `limit` - Optional. Maximum number of categories to show. Defaults to 3.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.category_nav categories={@categories} mode={:preview} />
<.category_nav categories={@categories} limit={4} />
"""
attr :categories, :list, required: true
attr :limit, :integer, default: 3
attr :mode, :atom, default: :live
def category_nav(assigns) do
~H"""
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-base);">
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
<%= for category <- Enum.take(@categories, @limit) do %>
<%= if @mode == :preview do %>
<a href="#" phx-click="change_preview_page" phx-value-page="collection" class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none; cursor: pointer;">
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
style={"background-image: url('#{category.image_url}');"}
></div>
<span class="text-sm font-medium" style="font-family: var(--t-font-body); color: var(--t-text-primary);">
<%= category.name %>
</span>
</a>
<% else %>
<a href={"/products?category=#{category[:slug] || category.name}"} class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none;">
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
style={"background-image: url('#{category.image_url}');"}
></div>
<span class="text-sm font-medium" style="font-family: var(--t-font-body); color: var(--t-text-primary);">
<%= category.name %>
</span>
</a>
<% end %>
<% end %>
</nav>
</section>
"""
end
@doc """
Renders a featured products section with title, product grid, and view-all button.
## Attributes
* `title` - Required. Section heading text.
* `products` - Required. List of products to display.
* `theme_settings` - Required. The theme settings map.
* `limit` - Optional. Maximum products to show. Defaults to 4.
* `mode` - Either `:live` (default) or `:preview`.
* `cta_text` - Optional. Text for the "view all" button. Defaults to "View all products".
* `cta_page` - Optional. Page to navigate to (preview mode). Defaults to "collection".
* `cta_href` - Optional. URL for live mode. Defaults to "/products".
## Examples
<.featured_products_section
title="Featured products"
products={@products}
theme_settings={@theme_settings}
mode={:preview}
/>
"""
attr :title, :string, required: true
attr :products, :list, required: true
attr :theme_settings, :map, required: true
attr :limit, :integer, default: 4
attr :mode, :atom, default: :live
attr :cta_text, :string, default: "View all products"
attr :cta_page, :string, default: "collection"
attr :cta_href, :string, default: "/products"
def featured_products_section(assigns) do
~H"""
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-sunken);">
<h2 class="text-2xl mb-6" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
<%= @title %>
</h2>
<.product_grid theme_settings={@theme_settings}>
<%= for product <- Enum.take(@products, @limit) do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:featured}
/>
<% end %>
</.product_grid>
<div class="text-center mt-8">
<%= if @mode == :preview do %>
<button
phx-click="change_preview_page"
phx-value-page={@cta_page}
class="px-6 py-3 font-medium transition-all"
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-text-primary); border-radius: var(--t-radius-button); cursor: pointer;"
>
<%= @cta_text %>
</button>
<% else %>
<a
href={@cta_href}
class="inline-block px-6 py-3 font-medium transition-all"
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;"
>
<%= @cta_text %>
</a>
<% end %>
</div>
</section>
"""
end
@doc """
Renders a two-column image + text section.
## Attributes
* `title` - Required. Section heading text.
* `description` - Required. Body text content.
* `image_url` - Required. URL for the image.
* `link_text` - Optional. Text for the link. If nil, no link is shown.
* `link_page` - Optional. Page to navigate to (preview mode).
* `link_href` - Optional. URL for live mode.
* `image_position` - Either `:left` (default) or `:right`.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.image_text_section
title="Made with passion, printed with care"
description="Every design starts with an idea..."
image_url="/mockups/example.jpg"
link_text="Learn more about the studio →"
link_page="about"
mode={:preview}
/>
"""
attr :title, :string, required: true
attr :description, :string, required: true
attr :image_url, :string, required: true
attr :link_text, :string, default: nil
attr :link_page, :string, default: nil
attr :link_href, :string, default: nil
attr :image_position, :atom, default: :left
attr :mode, :atom, default: :live
def image_text_section(assigns) do
~H"""
<section class="grid grid-cols-1 md:grid-cols-2 gap-12 items-center" style="padding: var(--space-2xl) var(--space-lg); background-color: var(--t-surface-base);">
<%= if @image_position == :left do %>
<.image_text_image image_url={@image_url} />
<.image_text_content title={@title} description={@description} link_text={@link_text} link_page={@link_page} link_href={@link_href} mode={@mode} />
<% else %>
<.image_text_content title={@title} description={@description} link_text={@link_text} link_page={@link_page} link_href={@link_href} mode={@mode} />
<.image_text_image image_url={@image_url} />
<% end %>
</section>
"""
end
attr :image_url, :string, required: true
defp image_text_image(assigns) do
~H"""
<div
class="h-72 rounded-lg bg-cover bg-center"
style={"background-image: url('#{@image_url}'); border-radius: var(--t-radius-image);"}
></div>
"""
end
attr :title, :string, required: true
attr :description, :string, required: true
attr :link_text, :string, default: nil
attr :link_page, :string, default: nil
attr :link_href, :string, default: nil
attr :mode, :atom, required: true
defp image_text_content(assigns) do
~H"""
<div>
<h2 class="text-2xl mb-4" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
<%= @title %>
</h2>
<p class="text-base mb-4" style="color: var(--t-text-secondary); line-height: 1.7;">
<%= @description %>
</p>
<%= if @link_text do %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@link_page}
class="text-sm font-medium transition-colors"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none; cursor: pointer;"
>
<%= @link_text %>
</a>
<% else %>
<a
href={@link_href || "/"}
class="text-sm font-medium transition-colors"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;"
>
<%= @link_text %>
</a>
<% end %>
<% end %>
</div>
"""
end
@doc """
Renders a page header with title and optional product count.
## Attributes
* `title` - Required. The page title.
* `subtitle` - Optional. Text below the title.
* `product_count` - Optional. Number to display as "X products".
## Examples
<.collection_header title="All Products" product_count={24} />
"""
attr :title, :string, required: true
attr :subtitle, :string, default: nil
attr :product_count, :integer, default: nil
def collection_header(assigns) do
~H"""
<div class="border-b" style="background-color: var(--t-surface-raised); border-color: var(--t-border-default);">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl md:text-4xl font-bold mb-2" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
<%= @title %>
</h1>
<%= if @subtitle do %>
<p style="color: var(--t-text-secondary);"><%= @subtitle %></p>
<% end %>
<%= if @product_count do %>
<p style="color: var(--t-text-secondary);"><%= @product_count %> products</p>
<% end %>
</div>
</div>
"""
end
@doc """
Renders a filter bar with category pills and sort dropdown.
## Attributes
* `categories` - Required. List of category maps with `name` field.
* `active_category` - Optional. Currently selected category name. Defaults to "All".
* `sort_options` - Optional. List of sort option strings.
## Examples
<.filter_bar categories={@categories} />
<.filter_bar categories={@categories} active_category="Art Prints" />
"""
attr :categories, :list, required: true
attr :active_category, :string, default: "All"
attr :sort_options, :list, default: ["Sort by: Featured", "Price: Low to High", "Price: High to Low", "Newest", "Best Selling"]
def filter_bar(assigns) do
~H"""
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Category Pills -->
<div class="filter-pills-container flex gap-2 overflow-x-auto">
<button class={"filter-pill#{if @active_category == "All", do: " filter-pill-active", else: ""}"}>
All
</button>
<%= for category <- @categories do %>
<button class={"filter-pill#{if @active_category == category.name, do: " filter-pill-active", else: ""}"}>
<%= category.name %>
</button>
<% end %>
</div>
<!-- Sort Dropdown -->
<select
class="px-4 py-2"
style="background-color: var(--t-surface-raised); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
>
<%= for option <- @sort_options do %>
<option><%= option %></option>
<% end %>
</select>
</div>
"""
end
@doc """
Renders a content body container for long-form content pages (about, etc.).
## Attributes
* `image_url` - Optional. Header image URL.
## Slots
* `inner_block` - Required. The content to render.
## Examples
<.content_body image_url="/images/about.jpg">
<p>Content here...</p>
</.content_body>
"""
attr :image_url, :string, default: nil
slot :inner_block, required: true
def content_body(assigns) do
~H"""
<div class="content-body" style="padding: var(--space-xl) var(--space-lg); max-width: 800px; margin: 0 auto;">
<%= if @image_url do %>
<div
class="content-image about-image"
style={"width: 100%; height: 300px; border-radius: var(--t-radius-image); margin-bottom: var(--space-lg); background-size: cover; background-position: center; background-image: url('#{@image_url}');"}
></div>
<% end %>
<div class="content-text" style="line-height: 1.7;">
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
@doc """
Renders a contact form card.
## Attributes
* `title` - Optional. Form heading. Defaults to "Send us a message".
## Examples
<.contact_form />
<.contact_form title="Get in touch" />
"""
attr :title, :string, default: "Send us a message"
def contact_form(assigns) do
~H"""
<div
class="p-8"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
<%= @title %>
</h2>
<form class="space-y-4">
<div>
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
Name
</label>
<input
type="text"
placeholder="Your name"
class="w-full px-4 py-2"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
/>
</div>
<div>
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
Email
</label>
<input
type="email"
placeholder="your@email.com"
class="w-full px-4 py-2"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
/>
</div>
<div>
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
Subject
</label>
<input
type="text"
placeholder="How can we help?"
class="w-full px-4 py-2"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
/>
</div>
<div>
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
Message
</label>
<textarea
rows="5"
placeholder="Your message..."
class="w-full px-4 py-2"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
></textarea>
</div>
<button
type="submit"
class="w-full px-6 py-3 font-semibold transition-all"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
>
Send Message
</button>
</form>
</div>
"""
end
@doc """
Renders the order tracking card.
## Examples
<.order_tracking_card />
"""
def order_tracking_card(assigns) do
~H"""
<div
class="p-6"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Track your order</h3>
<p class="text-sm mb-3" style="color: var(--t-text-secondary);">
Enter your email and we'll send you a link to check your order status.
</p>
<div class="flex flex-wrap gap-2">
<input
type="email"
placeholder="your@email.com"
class="flex-1 min-w-0 px-3 py-2 text-sm"
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input); min-width: 150px;"
/>
<button
class="px-4 py-2 text-sm font-medium whitespace-nowrap"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
>
Send
</button>
</div>
</div>
"""
end
@doc """
Renders the info card with bullet points (e.g., "Handy to know" section).
## Attributes
* `title` - Required. Card heading.
* `items` - Required. List of maps with `label` and `value` keys.
## Examples
<.info_card title="Handy to know" items={[
%{label: "Printing", value: "2-5 business days"},
%{label: "Delivery", value: "3-7 business days after printing"}
]} />
"""
attr :title, :string, required: true
attr :items, :list, required: true
def info_card(assigns) do
~H"""
<div
class="p-6"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);"><%= @title %></h3>
<ul class="space-y-2 text-sm" style="color: var(--t-text-secondary);">
<%= for item <- @items do %>
<li class="flex items-start gap-2">
<span style="color: var(--t-text-tertiary);">•</span>
<span><strong style="color: var(--t-text-primary);"><%= item.label %>:</strong> <%= item.value %></span>
</li>
<% end %>
</ul>
</div>
"""
end
@doc """
Renders the contact info card with email link.
## Attributes
* `title` - Optional. Card heading. Defaults to "Get in touch".
* `email` - Required. Email address.
* `response_text` - Optional. Response time text. Defaults to "We typically respond within 24 hours".
## Examples
<.contact_info_card email="hello@example.com" />
"""
attr :title, :string, default: "Get in touch"
attr :email, :string, required: true
attr :response_text, :string, default: "We typically respond within 24 hours"
def contact_info_card(assigns) do
~H"""
<div
class="p-6"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);"><%= @title %></h3>
<a href={"mailto:#{@email}"} class="flex items-center gap-2 mb-2" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<%= @email %>
</a>
<p class="text-sm" style="color: var(--t-text-secondary);">
<%= @response_text %>
</p>
</div>
"""
end
@doc """
Renders social media icon links.
## Attributes
* `links` - Optional. List of maps with `platform`, `url`, and `label` keys.
Supported platforms: :instagram, :pinterest
## Examples
<.social_links />
<.social_links links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} />
"""
attr :links, :list, default: [
%{platform: :instagram, url: "#", label: "Instagram"},
%{platform: :pinterest, url: "#", label: "Pinterest"}
]
def social_links(assigns) do
~H"""
<div class="flex gap-4 justify-center">
<%= for link <- @links do %>
<a
href={link.url}
class="social-link w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
aria-label={link.label}
>
<.social_icon platform={link.platform} />
</a>
<% end %>
</div>
"""
end
attr :platform, :atom, required: true
defp social_icon(%{platform: :instagram} = assigns) do
~H"""
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
</svg>
"""
end
defp social_icon(%{platform: :pinterest} = assigns) do
~H"""
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 12c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4"></path>
<line x1="12" y1="16" x2="9" y2="21"></line>
</svg>
"""
end
defp social_icon(assigns) do
~H"""
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
</svg>
"""
end
@doc """
Renders a cart item row.
## Attributes
* `item` - Required. Map with `product` (containing `image_url`, `name`, `price`), `variant`, and `quantity`.
* `currency` - Optional. Currency symbol. Defaults to "£".
## Examples
<.cart_item item={item} />
"""
attr :item, :map, required: true
attr :currency, :string, default: "£"
def cart_item(assigns) do
~H"""
<div
class="flex gap-4 p-4"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<div class="w-24 h-24 flex-shrink-0 bg-gray-200 overflow-hidden" style="border-radius: var(--t-radius-image);">
<img
src={@item.product.image_url}
alt={@item.product.name}
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1">
<h3 class="font-semibold mb-1" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
<%= @item.product.name %>
</h3>
<p class="text-sm mb-2" style="color: var(--t-text-secondary);">
<%= @item.variant %>
</p>
<div class="flex items-center gap-4">
<div class="flex items-center" style="border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);">
<button class="px-3 py-1" style="color: var(--t-text-primary);"></button>
<span class="px-3 py-1 border-x" style="border-color: var(--t-border-default); color: var(--t-text-primary);">
<%= @item.quantity %>
</span>
<button class="px-3 py-1" style="color: var(--t-text-primary);">+</button>
</div>
<button class="text-sm" style="color: var(--t-text-tertiary);">
Remove
</button>
</div>
</div>
<div class="text-right">
<p class="font-bold text-lg" style="color: var(--t-text-primary);">
<%= @currency %><%= @item.product.price / 100 * @item.quantity %>
</p>
</div>
</div>
"""
end
@doc """
Renders the order summary card.
## Attributes
* `subtotal` - Required. Subtotal amount (in pence/cents).
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00).
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20).
* `currency` - Optional. Currency symbol. Defaults to "£".
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.order_summary subtotal={3600} />
"""
attr :subtotal, :integer, required: true
attr :delivery, :integer, default: 800
attr :vat, :integer, default: 720
attr :currency, :string, default: "£"
attr :mode, :atom, default: :live
def order_summary(assigns) do
total = assigns.subtotal + assigns.delivery + assigns.vat
assigns = assign(assigns, :total, total)
~H"""
<div
class="p-6 sticky top-4"
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
Order Summary
</h2>
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Subtotal</span>
<span style="color: var(--t-text-primary);">
<%= @currency %><%= Float.round(@subtotal / 100, 2) %>
</span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Delivery</span>
<span style="color: var(--t-text-primary);"><%= @currency %><%= Float.round(@delivery / 100, 2) %></span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
<span style="color: var(--t-text-primary);"><%= @currency %><%= Float.round(@vat / 100, 2) %></span>
</div>
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
<span class="font-bold" style="color: var(--t-text-primary);">
<%= @currency %><%= Float.round(@total / 100, 2) %>
</span>
</div>
</div>
</div>
<button
class="w-full px-6 py-3 font-semibold transition-all mb-3"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
>
Checkout
</button>
<%= if @mode == :preview do %>
<button
phx-click="change_preview_page"
phx-value-page="collection"
class="w-full px-6 py-3 font-semibold transition-all"
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
>
Continue Shopping
</button>
<% else %>
<a
href="/products"
class="block w-full px-6 py-3 font-semibold transition-all text-center"
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;"
>
Continue Shopping
</a>
<% end %>
</div>
"""
end
@doc """
Renders a breadcrumb navigation.
## Attributes
* `items` - Required. List of breadcrumb items. Each item is a map with:
- `label` - Required. Display text
- `page` - Optional. Page name for preview mode navigation
- `href` - Optional. URL for live mode navigation
- `current` - Optional. Boolean, if true this is the current page (not a link)
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.breadcrumb items={[
%{label: "Home", page: "home", href: "/"},
%{label: "Art Prints", page: "collection", href: "/products?category=art-prints"},
%{label: "Mountain Sunrise", current: true}
]} mode={:preview} />
"""
attr :items, :list, required: true
attr :mode, :atom, default: :live
def breadcrumb(assigns) do
~H"""
<nav class="mb-8 flex items-center gap-2 text-sm" style="color: var(--t-text-secondary);">
<%= for {item, index} <- Enum.with_index(@items) do %>
<%= if index > 0 do %>
<span>/</span>
<% end %>
<%= if item[:current] do %>
<span style="color: var(--t-text-primary);"><%= item.label %></span>
<% else %>
<%= if @mode == :preview do %>
<a href="#" phx-click="change_preview_page" phx-value-page={item.page} class="hover:underline"><%= item.label %></a>
<% else %>
<a href={item.href || "/"} class="hover:underline"><%= item.label %></a>
<% end %>
<% end %>
<% end %>
</nav>
"""
end
@doc """
Renders a related products section with title and product grid.
## Attributes
* `title` - Optional. Section heading. Defaults to "You might also like".
* `products` - Required. List of products to display.
* `theme_settings` - Required. The theme settings map.
* `limit` - Optional. Maximum products to show. Defaults to 4.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.related_products_section
products={@related_products}
theme_settings={@theme_settings}
mode={:preview}
/>
"""
attr :title, :string, default: "You might also like"
attr :products, :list, required: true
attr :theme_settings, :map, required: true
attr :limit, :integer, default: 4
attr :mode, :atom, default: :live
def related_products_section(assigns) do
~H"""
<div class="py-12" style="border-top: 1px solid var(--t-border-default);">
<h2 class="text-2xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
<%= @title %>
</h2>
<.product_grid columns={:fixed_4} gap="gap-6">
<%= for product <- Enum.take(@products, @limit) do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:compact}
/>
<% end %>
</.product_grid>
</div>
"""
end
end