berrypod/lib/simpleshop_theme_web/components/shop_components.ex
Jamey Greenwood 9746bf183c refactor: extract product_card to shared ShopComponents module
Create a reusable product card component with four variants:
- :default (collection page) - full details with category
- :featured (home page) - hover animation, badges
- :compact (pdp related) - smaller, no badges/delivery
- :minimal (error 404) - smallest, not clickable

Accessibility improvements:
- Use <a> for all clickable cards (keyboard nav, screen readers)
- Use <div> only for non-clickable decorative cards

Unified badge logic with priority: Sold out > New > Sale

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

785 lines
35 KiB
Elixir

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);"
end