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>
This commit is contained in:
Jamey Greenwood 2026-01-17 15:14:25 +00:00
parent 1589ebaeca
commit 0c15929c19
7 changed files with 909 additions and 396 deletions

View File

@ -1043,4 +1043,846 @@ defmodule SimpleshopThemeWeb.ShopComponents do
defp hero_cta_style(:secondary) do 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;" "border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
end 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 end

View File

@ -20,15 +20,7 @@
/> />
<!-- Content Body --> <!-- Content Body -->
<div class="content-body" style="padding: var(--space-xl) var(--space-lg); max-width: 800px; margin: 0 auto;"> <.content_body image_url="/mockups/night-sky-blanket-3.jpg">
<!-- About Image -->
<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('/mockups/night-sky-blanket-3.jpg');"
></div>
<!-- Content Text -->
<div class="content-text" style="line-height: 1.7;">
<p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);"> <p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);">
I'm Emma, a nature photographer based in the UK. What started as weekend walks with my camera has grown into something I never expected a little shop where I can share my favourite captures with others. I'm Emma, a nature photographer based in the UK. What started as weekend walks with my camera has grown into something I never expected a little shop where I can share my favourite captures with others.
</p> </p>
@ -58,8 +50,7 @@
<p class="mt-8" style="color: var(--t-text-secondary);"> <p class="mt-8" style="color: var(--t-text-secondary);">
Thank you for visiting. It means a lot that you're here. Thank you for visiting. It means a lot that you're here.
</p> </p>
</div> </.content_body>
</div>
</main> </main>
<!-- Footer --> <!-- Footer -->

View File

@ -1,3 +1,6 @@
<%
subtotal = Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end)
%>
<div class="shop-container min-h-screen" style="position: relative; background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen" style="position: relative; background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<!-- Skip Link for Accessibility --> <!-- Skip Link for Accessibility -->
<.skip_link /> <.skip_link />
@ -20,100 +23,14 @@
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="space-y-4"> <div class="space-y-4">
<%= for item <- @preview_data.cart_items do %> <%= for item <- @preview_data.cart_items do %>
<div <.cart_item item={item} currency="$" />
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);">
$<%= item.product.price / 100 * item.quantity %>
</p>
</div>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>
<!-- Order Summary --> <!-- Order Summary -->
<div> <div>
<div <.order_summary subtotal={subtotal} mode={:preview} />
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);">
$<%= Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end) / 100 %>
</span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Delivery</span>
<span style="color: var(--t-text-primary);">£8.00</span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
<span style="color: var(--t-text-primary);">£7.20</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);">
£<%= (Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end) / 100 + 8.00 + 7.20) |> Float.round(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>
<button
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);"
>
Continue Shopping
</button>
</div>
</div> </div>
</div> </div>
</main> </main>

View File

@ -12,44 +12,11 @@
<!-- Page Header --> <!-- Page Header -->
<main id="main-content"> <main id="main-content">
<div class="border-b" style="background-color: var(--t-surface-raised); border-color: var(--t-border-default);"> <.collection_header title="All Products" product_count={length(@preview_data.products)} />
<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);">
All Products
</h1>
<p style="color: var(--t-text-secondary);">
<%= length(@preview_data.products) %> products
</p>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Filter Bar: Category Pills + Sort --> <!-- Filter Bar: Category Pills + Sort -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <.filter_bar categories={@preview_data.categories} />
<!-- Category Pills -->
<div class="filter-pills-container flex gap-2 overflow-x-auto">
<button class="filter-pill filter-pill-active">
All
</button>
<%= for category <- @preview_data.categories do %>
<button class="filter-pill">
<%= 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);"
>
<option>Sort by: Featured</option>
<option>Price: Low to High</option>
<option>Price: High to Low</option>
<option>Newest</option>
<option>Best Selling</option>
</select>
</div>
<!-- Product Grid --> <!-- Product Grid -->
<.product_grid theme_settings={@theme_settings}> <.product_grid theme_settings={@theme_settings}>

View File

@ -19,166 +19,25 @@
<div class="grid gap-8 md:grid-cols-2 mb-12"> <div class="grid gap-8 md:grid-cols-2 mb-12">
<!-- Contact Form --> <!-- Contact Form -->
<div <.contact_form />
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);">
Send us a message
</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>
<!-- Contact Info --> <!-- Contact Info -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Order Tracking --> <!-- Order Tracking -->
<div <.order_tracking_card />
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>
<!-- Helpful info --> <!-- Helpful info -->
<div <.info_card title="Handy to know" items={[
class="p-6" %{label: "Printing", value: "2-5 business days"},
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);" %{label: "Delivery", value: "3-7 business days after printing"},
> %{label: "Returns", value: "Happy to help with faulty or damaged items"}
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Handy to know</h3> ]} />
<ul class="space-y-2 text-sm" style="color: var(--t-text-secondary);">
<li class="flex items-start gap-2">
<span style="color: var(--t-text-tertiary);">•</span>
<span><strong style="color: var(--t-text-primary);">Printing:</strong> 2-5 business days</span>
</li>
<li class="flex items-start gap-2">
<span style="color: var(--t-text-tertiary);">•</span>
<span><strong style="color: var(--t-text-primary);">Delivery:</strong> 3-7 business days after printing</span>
</li>
<li class="flex items-start gap-2">
<span style="color: var(--t-text-tertiary);">•</span>
<span><strong style="color: var(--t-text-primary);">Returns:</strong> Happy to help with faulty or damaged items</span>
</li>
</ul>
</div>
<!-- Get in touch --> <!-- Get in touch -->
<div <.contact_info_card email="hello@example.com" />
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);">Get in touch</h3>
<a href="mailto:hello@example.com" 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>
hello@example.com
</a>
<p class="text-sm" style="color: var(--t-text-secondary);">
We typically respond within 24 hours
</p>
</div>
<!-- Social links --> <!-- Social links -->
<div class="flex gap-4 justify-center"> <.social_links />
<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>
</div> </div>
</main> </main>

View File

@ -21,75 +21,25 @@
/> />
<!-- Categories --> <!-- Categories -->
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-base);"> <.category_nav categories={@preview_data.categories} mode={:preview} />
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
<%= for category <- Enum.take(@preview_data.categories, 3) 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>
<% end %>
</nav>
</section>
<!-- Featured Products --> <!-- Featured Products -->
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-sunken);"> <.featured_products_section
<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="Featured products"
Featured products products={@preview_data.products}
</h2>
<.product_grid theme_settings={@theme_settings}>
<%= for product <- Enum.take(@preview_data.products, 4) do %>
<.product_card
product={product}
theme_settings={@theme_settings} theme_settings={@theme_settings}
mode={:preview} mode={:preview}
variant={:featured}
/> />
<% end %>
</.product_grid>
<div class="text-center mt-8">
<button
phx-click="change_preview_page"
phx-value-page="collection"
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;"
>
View all products
</button>
</div>
</section>
<!-- About Section --> <!-- About Section -->
<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);"> <.image_text_section
<div title="Made with passion, printed with care"
class="h-72 rounded-lg bg-cover bg-center" description="Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products from gallery-quality art prints to everyday essentials."
style="background-image: url('/mockups/mountain-sunrise-print-3.jpg'); border-radius: var(--t-radius-image);" image_url="/mockups/mountain-sunrise-print-3.jpg"
></div> link_text="Learn more about the studio →"
<div> link_page="about"
<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);"> mode={:preview}
Made with passion, printed with care />
</h2>
<p class="text-base mb-4" style="color: var(--t-text-secondary); line-height: 1.7;">
Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products from gallery-quality art prints to everyday essentials.
</p>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="about"
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;"
>
Learn more about the studio →
</a>
</div>
</section>
</main> </main>
<!-- Footer --> <!-- Footer -->

View File

@ -15,13 +15,11 @@
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="mb-8 flex items-center gap-2 text-sm" style="color: var(--t-text-secondary);"> <.breadcrumb items={[
<a href="#" phx-click="change_preview_page" phx-value-page="home" class="hover:underline">Home</a> %{label: "Home", page: "home", href: "/"},
<span>/</span> %{label: product.category, page: "collection", href: "/products"},
<a href="#" phx-click="change_preview_page" phx-value-page="collection" class="hover:underline"><%= product.category %></a> %{label: product.name, current: true}
<span>/</span> ]} mode={:preview} />
<span style="color: var(--t-text-primary);"><%= product.name %></span>
</nav>
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16"> <div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
<!-- Product Images --> <!-- Product Images -->
@ -397,23 +395,12 @@
<!-- Related Products --> <!-- Related Products -->
<%= if @theme_settings.pdp_related_products do %> <%= if @theme_settings.pdp_related_products do %>
<div class="py-12" style="border-top: 1px solid var(--t-border-default);"> <.related_products_section
<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);"> products={Enum.slice(@preview_data.products, 1, 4)}
You might also like
</h2>
<.product_grid columns={:fixed_4} gap="gap-6">
<%= for related_product <- Enum.slice(@preview_data.products, 1, 4) do %>
<.product_card
product={related_product}
theme_settings={@theme_settings} theme_settings={@theme_settings}
mode={:preview} mode={:preview}
variant={:compact}
/> />
<% end %> <% end %>
</.product_grid>
</div>
<% end %>
</main> </main>
<!-- Footer --> <!-- Footer -->