refactor: extract remaining PDP components to ShopComponents

Add PDP-specific components:
- product_gallery with lightbox
- product_info (title, price, sale badge)
- variant_selector
- quantity_selector
- add_to_cart_button
- product_details accordion
- star_rating
- trust_badges
- reviews_section with review_card

Add page layout components:
- page_title for consistent h1 styling
- cart_layout for cart page structure
- rich_text for structured content blocks
- accordion_item for generic collapsible sections

Update preview pages to be fully component-based:
- PDP: 415 → 48 lines (88% reduction)
- Cart: 47 → 23 lines
- About: 65 → 27 lines

Add preview data functions:
- reviews() for product reviews
- about_content() for rich text blocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-17 15:37:58 +00:00
parent 0c15929c19
commit 97981a9884
5 changed files with 813 additions and 447 deletions

View File

@ -57,6 +57,50 @@ defmodule SimpleshopTheme.Theme.PreviewData do
mock_testimonials() mock_testimonials()
end end
@doc """
Returns product reviews for preview.
Always returns mock data formatted for the reviews_section component.
"""
def reviews do
[
%{
rating: 5,
title: "Absolutely beautiful",
body: "The quality exceeded my expectations. The colours are vibrant and the paper feels premium. It's now pride of place in my living room.",
author: "Sarah M.",
date: "2 weeks ago",
verified: true
},
%{
rating: 4,
title: "Great gift",
body: "Bought this as a gift and it arrived beautifully packaged. Fast shipping too. Would definitely order again.",
author: "James T.",
date: "1 month ago",
verified: true
}
]
end
@doc """
Returns about page content for preview.
Returns structured content blocks for the rich_text component.
"""
def about_content do
[
%{type: :lead, text: "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."},
%{type: :paragraph, text: "Every design in this shop comes from my own photography. Whether it's early morning mist over the hills, autumn leaves in the local woods, or the quiet beauty of wildflower meadows, I'm drawn to the peaceful moments that nature offers."},
%{type: :paragraph, text: "I work with quality print partners to bring these images to life on products you can actually use and enjoy from art prints for your walls to mugs for your morning tea."},
%{type: :heading, text: "Quality you can trust"},
%{type: :paragraph, text: "I've carefully chosen print partners who share my commitment to quality. Every product is made to order using premium materials and printing techniques that ensure vibrant colours and lasting quality."},
%{type: :heading, text: "Printed sustainably"},
%{type: :paragraph, text: "Because each item is printed on demand, there's no waste from unsold stock. My print partners use eco-friendly inks where possible, and products are shipped directly to you to minimise unnecessary handling."},
%{type: :closing, text: "Thank you for visiting. It means a lot that you're here."}
]
end
@doc """ @doc """
Returns categories for preview. Returns categories for preview.

View File

@ -1885,4 +1885,754 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</div> </div>
""" """
end end
@doc """
Renders a star rating display.
## Attributes
* `rating` - Required. Number of filled stars (1-5).
* `max` - Optional. Maximum stars to display. Defaults to 5.
* `size` - Optional. Size variant: `:sm` (w-4), `:md` (w-5). Defaults to `:sm`.
* `color` - Optional. Star color. Defaults to "#f59e0b" (amber).
## Examples
<.star_rating rating={5} />
<.star_rating rating={4} size={:md} />
"""
attr :rating, :integer, required: true
attr :max, :integer, default: 5
attr :size, :atom, default: :sm
attr :color, :string, default: "#f59e0b"
def star_rating(assigns) do
size_class = if assigns.size == :md, do: "w-5 h-5", else: "w-4 h-4"
assigns = assign(assigns, :size_class, size_class)
~H"""
<div class="flex gap-0.5">
<%= for i <- 1..@max do %>
<svg class={@size_class} viewBox="0 0 20 20" style={"color: #{if i <= @rating, do: @color, else: "var(--t-border-default)"};"}>
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" fill="currentColor"/>
</svg>
<% end %>
</div>
"""
end
@doc """
Renders trust badges (e.g., Free Delivery, Easy Returns).
## Attributes
* `items` - Optional. List of badge items. Each item is a map with:
- `icon` - Icon type: `:check` or `:shield`
- `title` - Badge title
- `description` - Badge description
Defaults to Free Delivery and Easy Returns badges.
## Examples
<.trust_badges />
<.trust_badges items={[%{icon: :check, title: "Custom", description: "Badge text"}]} />
"""
attr :items, :list, default: [
%{icon: :check, title: "Free Delivery", description: "On orders over £40"},
%{icon: :shield, title: "Easy Returns", description: "30-day return policy"}
]
def trust_badges(assigns) do
~H"""
<div class="p-4 space-y-3" style="background-color: var(--t-surface-sunken); border-radius: var(--t-radius-card);">
<%= for item <- @items do %>
<div class="flex items-start gap-3">
<.trust_badge_icon icon={item.icon} />
<div>
<p class="font-semibold" style="color: var(--t-text-primary);"><%= item.title %></p>
<p class="text-sm" style="color: var(--t-text-secondary);"><%= item.description %></p>
</div>
</div>
<% end %>
</div>
"""
end
attr :icon, :atom, required: true
defp trust_badge_icon(%{icon: :check} = assigns) do
~H"""
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
"""
end
defp trust_badge_icon(%{icon: :shield} = assigns) do
~H"""
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"""
end
defp trust_badge_icon(assigns) do
~H"""
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
"""
end
@doc """
Renders a customer reviews section with collapsible header and review cards.
## Attributes
* `reviews` - Required. List of review maps with:
- `rating` - Star rating (1-5)
- `title` - Review title
- `body` - Review text
- `author` - Reviewer name
- `date` - Relative date string (e.g., "2 weeks ago")
- `verified` - Boolean, if true shows "Verified purchase" badge
* `average_rating` - Optional. Average rating to show in header. Defaults to 5.
* `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
* `open` - Optional. Whether section is expanded by default. Defaults to true.
## Examples
<.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} />
"""
attr :reviews, :list, required: true
attr :average_rating, :integer, default: 5
attr :total_count, :integer, default: nil
attr :open, :boolean, default: true
def reviews_section(assigns) do
assigns = assign_new(assigns, :display_count, fn ->
assigns.total_count || length(assigns.reviews)
end)
~H"""
<details open={@open} class="pdp-reviews group" style="border-top: 1px solid var(--t-border-default);">
<summary class="flex justify-between items-center py-6 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<h2 class="text-2xl font-bold" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
Customer reviews
</h2>
<div class="flex items-center gap-2">
<.star_rating rating={@average_rating} />
<span class="text-sm" style="color: var(--t-text-secondary);">(<%= @display_count %>)</span>
</div>
</div>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-8">
<div class="space-y-6">
<%= for review <- @reviews do %>
<.review_card review={review} />
<% end %>
</div>
<button
class="mt-6 px-6 py-2 text-sm font-medium transition-all mx-auto block"
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-button);"
>
Load more reviews
</button>
</div>
</details>
"""
end
@doc """
Renders a single review card.
## Attributes
* `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`.
## Examples
<.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} />
"""
attr :review, :map, required: true
def review_card(assigns) do
~H"""
<article class="pb-6" style="border-bottom: 1px solid var(--t-border-subtle);">
<div class="flex items-center justify-between mb-2">
<.star_rating rating={@review.rating} />
<span class="text-xs" style="color: var(--t-text-tertiary);"><%= @review.date %></span>
</div>
<h3 class="font-semibold mb-1" style="color: var(--t-text-primary);"><%= @review.title %></h3>
<p class="text-sm mb-3" style="color: var(--t-text-secondary); line-height: 1.6;">
<%= @review.body %>
</p>
<div class="flex items-center gap-2">
<span class="text-sm font-medium" style="color: var(--t-text-primary);"><%= @review.author %></span>
<%= if @review.verified do %>
<span class="text-xs px-2 py-0.5 rounded" style="background-color: var(--t-surface-sunken); color: var(--t-text-tertiary);">Verified purchase</span>
<% end %>
</div>
</article>
"""
end
@doc """
Renders a product image gallery with thumbnails and lightbox.
## Attributes
* `images` - Required. List of image URLs.
* `product_name` - Required. Product name for alt text.
* `id_prefix` - Optional. Prefix for element IDs. Defaults to "pdp".
## Examples
<.product_gallery images={@product_images} product_name={@product.name} />
"""
attr :images, :list, required: true
attr :product_name, :string, required: true
attr :id_prefix, :string, default: "pdp"
def product_gallery(assigns) do
~H"""
<div>
<div
class="pdp-main-image-container aspect-square bg-gray-200 mb-4 overflow-hidden relative"
style="border-radius: var(--t-radius-image);"
phx-click={Phoenix.LiveView.JS.exec("data-show", to: "##{@id_prefix}-lightbox")}
>
<img
id={"#{@id_prefix}-main-image"}
src={List.first(@images)}
alt={@product_name}
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/10">
<div class="w-12 h-12 bg-white/90 rounded-full flex items-center justify-center shadow-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<%= for {img_url, idx} <- Enum.with_index(@images) do %>
<button
type="button"
class={"aspect-square bg-gray-200 cursor-pointer overflow-hidden pdp-thumbnail#{if idx == 0, do: " pdp-thumbnail-active", else: ""}"}
style="border-radius: var(--t-radius-image);"
data-index={idx}
phx-click={Phoenix.LiveView.JS.set_attribute({"src", img_url}, to: "##{@id_prefix}-main-image")
|> Phoenix.LiveView.JS.set_attribute({"data-current-index", to_string(idx)}, to: "##{@id_prefix}-lightbox")
|> Phoenix.LiveView.JS.remove_class("pdp-thumbnail-active", to: ".pdp-thumbnail")
|> Phoenix.LiveView.JS.add_class("pdp-thumbnail-active")}
>
<img
src={img_url}
alt={@product_name}
class="w-full h-full object-cover"
/>
</button>
<% end %>
</div>
<style>
.pdp-thumbnail {
border: 2px solid var(--t-border-default);
transition: border-color 0.15s ease;
}
.pdp-thumbnail-active {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
}
</style>
<.product_lightbox images={@images} product_name={@product_name} id_prefix={@id_prefix} />
</div>
"""
end
# Private: Renders a product image lightbox dialog used by `product_gallery`.
attr :images, :list, required: true
attr :product_name, :string, required: true
attr :id_prefix, :string, required: true
defp product_lightbox(assigns) do
~H"""
<dialog
class="lightbox"
id={"#{@id_prefix}-lightbox"}
aria-label="Product image gallery"
data-current-index="0"
data-images={Jason.encode!(@images)}
data-show={Phoenix.LiveView.JS.exec("phx-show-lightbox", to: "##{@id_prefix}-lightbox")}
phx-show-lightbox={Phoenix.LiveView.JS.dispatch("pdp:open-lightbox", to: "##{@id_prefix}-lightbox")}
phx-hook="Lightbox"
>
<div class="lightbox-content">
<button
type="button"
class="lightbox-close"
aria-label="Close gallery"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:close-lightbox", to: "##{@id_prefix}-lightbox")}
>
<svg 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>
<button
type="button"
class="lightbox-nav lightbox-prev"
aria-label="Previous image"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:prev-image", to: "##{@id_prefix}-lightbox")}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<figure class="lightbox-figure">
<div class="lightbox-image-container">
<img
class="lightbox-image"
id="lightbox-image"
src={List.first(@images)}
alt={@product_name}
/>
</div>
<figcaption class="lightbox-caption"><%= @product_name %></figcaption>
</figure>
<button
type="button"
class="lightbox-nav lightbox-next"
aria-label="Next image"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:next-image", to: "##{@id_prefix}-lightbox")}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div class="lightbox-counter" id="lightbox-counter">1 / <%= length(@images) %></div>
</div>
</dialog>
"""
end
@doc """
Renders product title and price information.
## Attributes
* `product` - Required. Product map with `name`, `price`, `on_sale`, `compare_at_price`.
* `currency` - Optional. Currency symbol. Defaults to "£".
## Examples
<.product_info product={@product} />
"""
attr :product, :map, required: true
attr :currency, :string, default: "£"
def product_info(assigns) do
~H"""
<div>
<h1 class="text-3xl md:text-4xl font-bold mb-4" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
<%= @product.name %>
</h1>
<div class="flex items-center gap-4 mb-6">
<%= if @product.on_sale do %>
<span class="text-3xl font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<%= @currency %><%= @product.price / 100 %>
</span>
<span class="text-xl line-through" style="color: var(--t-text-tertiary);">
<%= @currency %><%= @product.compare_at_price / 100 %>
</span>
<span class="px-2 py-1 text-sm font-bold text-white rounded" style="background-color: var(--t-sale-color);">
SAVE <%= round((@product.compare_at_price - @product.price) / @product.compare_at_price * 100) %>%
</span>
<% else %>
<span class="text-3xl font-bold" style="color: var(--t-text-primary);">
<%= @currency %><%= @product.price / 100 %>
</span>
<% end %>
</div>
</div>
"""
end
@doc """
Renders a variant selector with button options.
## Attributes
* `label` - Required. Label text (e.g., "Size", "Color").
* `options` - Required. List of option strings.
* `selected` - Optional. Currently selected option. Defaults to first option.
## Examples
<.variant_selector label="Size" options={["S", "M", "L", "XL"]} />
<.variant_selector label="Color" options={["Red", "Blue", "Green"]} selected="Blue" />
"""
attr :label, :string, required: true
attr :options, :list, required: true
attr :selected, :string, default: nil
def variant_selector(assigns) do
assigns = assign_new(assigns, :selected_value, fn ->
assigns.selected || List.first(assigns.options)
end)
~H"""
<div class="mb-6">
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
<%= @label %>
</label>
<div class="flex flex-wrap gap-2">
<%= for option <- @options do %>
<button
type="button"
class="px-4 py-2 font-medium transition-all"
style={"border: 2px solid #{if option == @selected_value, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; border-radius: var(--t-radius-button); color: var(--t-text-primary); background: #{if option == @selected_value, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
>
<%= option %>
</button>
<% end %>
</div>
</div>
"""
end
@doc """
Renders a quantity selector with increment/decrement buttons.
## Attributes
* `quantity` - Optional. Current quantity value. Defaults to 1.
* `in_stock` - Optional. Whether the product is in stock. Defaults to true.
* `min` - Optional. Minimum quantity. Defaults to 1.
* `max` - Optional. Maximum quantity. Defaults to 99.
## Examples
<.quantity_selector />
<.quantity_selector quantity={2} in_stock={false} />
"""
attr :quantity, :integer, default: 1
attr :in_stock, :boolean, default: true
attr :min, :integer, default: 1
attr :max, :integer, default: 99
def quantity_selector(assigns) do
~H"""
<div class="mb-8">
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
Quantity
</label>
<div class="flex items-center gap-4">
<div class="flex items-center" style="border: 2px solid var(--t-border-default); border-radius: var(--t-radius-input);">
<button type="button" class="px-4 py-2" style="color: var(--t-text-primary);"></button>
<span class="px-4 py-2 border-x-2" style="border-color: var(--t-border-default); color: var(--t-text-primary);">
<%= @quantity %>
</span>
<button type="button" class="px-4 py-2" style="color: var(--t-text-primary);">+</button>
</div>
<%= if @in_stock do %>
<span class="text-sm" style="color: var(--t-text-tertiary);">In stock</span>
<% else %>
<span class="text-sm font-semibold" style="color: var(--t-sale-color);">Out of stock</span>
<% end %>
</div>
</div>
"""
end
@doc """
Renders the add to cart button.
## Attributes
* `text` - Optional. Button text. Defaults to "Add to basket".
* `disabled` - Optional. Whether button is disabled. Defaults to false.
* `sticky` - Optional. Whether to use sticky positioning on mobile. Defaults to true.
## Examples
<.add_to_cart_button />
<.add_to_cart_button text="Add to bag" disabled={true} />
"""
attr :text, :string, default: "Add to basket"
attr :disabled, :boolean, default: false
attr :sticky, :boolean, default: true
def add_to_cart_button(assigns) do
~H"""
<div class={["mb-4", @sticky && "sticky bottom-0 z-10 py-3 md:relative md:py-0 -mx-4 px-4 md:mx-0 md:px-0 border-t md:border-0"]} style="background-color: var(--t-surface-base); border-color: var(--t-border-subtle);">
<button
type="button"
phx-click={Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay")}
disabled={@disabled}
class="w-full px-6 py-4 text-lg font-semibold transition-all"
style={"background-color: #{if @disabled, do: "var(--t-border-default)", else: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))"}; color: var(--t-text-inverse); border-radius: var(--t-radius-button); cursor: #{if @disabled, do: "not-allowed", else: "pointer"}; border: none;"}
>
<%= @text %>
</button>
</div>
"""
end
@doc """
Renders a collapsible details/accordion item.
## Attributes
* `title` - Required. Section heading text.
* `open` - Optional. Whether section is expanded by default. Defaults to false.
## Slots
* `inner_block` - Required. Content to show when expanded.
## Examples
<.accordion_item title="Description" open={true}>
<p>Product description here...</p>
</.accordion_item>
"""
attr :title, :string, required: true
attr :open, :boolean, default: false
slot :inner_block, required: true
def accordion_item(assigns) do
~H"""
<details open={@open} class="group">
<summary class="flex justify-between items-center py-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<span class="font-semibold"><%= @title %></span>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-4" style="color: var(--t-text-secondary);">
<%= render_slot(@inner_block) %>
</div>
</details>
"""
end
@doc """
Renders a product details accordion with Description, Size Guide, and Shipping sections.
## Attributes
* `product` - Required. Product map with `description`.
* `show_size_guide` - Optional. Whether to show size guide. Defaults to true.
* `size_guide` - Optional. Custom size guide data. Uses default if not provided.
## Examples
<.product_details product={@product} />
<.product_details product={@product} show_size_guide={false} />
"""
attr :product, :map, required: true
attr :show_size_guide, :boolean, default: true
attr :size_guide, :list, default: nil
def product_details(assigns) do
assigns = assign_new(assigns, :sizes, fn ->
assigns.size_guide || [
%{size: "S", chest: "86-91", length: "71"},
%{size: "M", chest: "91-96", length: "73"},
%{size: "L", chest: "96-101", length: "75"},
%{size: "XL", chest: "101-106", length: "77"}
]
end)
~H"""
<div class="mt-8 divide-y" style="border-top: 1px solid var(--t-border-subtle); border-bottom: 1px solid var(--t-border-subtle); border-color: var(--t-border-subtle);">
<.accordion_item title="Description" open={true}>
<p class="leading-relaxed"><%= @product.description %>. Crafted with attention to detail and quality materials, this product is designed to last. Perfect for everyday use or special occasions.</p>
</.accordion_item>
<%= if @show_size_guide do %>
<.accordion_item title="Size Guide">
<table class="w-full text-sm">
<thead>
<tr style="border-bottom: 1px solid var(--t-border-subtle);">
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Size</th>
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Chest (cm)</th>
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Length (cm)</th>
</tr>
</thead>
<tbody>
<%= for {size_row, idx} <- Enum.with_index(@sizes) do %>
<tr style={if idx < length(@sizes) - 1, do: "border-bottom: 1px solid var(--t-border-subtle);", else: ""}>
<td class="py-2"><%= size_row.size %></td>
<td class="py-2"><%= size_row.chest %></td>
<td class="py-2"><%= size_row.length %></td>
</tr>
<% end %>
</tbody>
</table>
</.accordion_item>
<% end %>
<.accordion_item title="Shipping & Returns">
<div class="space-y-3">
<div>
<p class="font-semibold mb-1" style="color: var(--t-text-primary);">Delivery</p>
<p class="text-sm">Free UK delivery on orders over £40. Standard delivery 3-5 working days. Express delivery available at checkout.</p>
</div>
<div>
<p class="font-semibold mb-1" style="color: var(--t-text-primary);">Returns</p>
<p class="text-sm">We offer a 30-day return policy. Items must be unused and in original packaging. Please contact us to arrange a return.</p>
</div>
</div>
</.accordion_item>
</div>
"""
end
@doc """
Renders a page title heading.
## Attributes
* `text` - Required. The title text.
* `class` - Optional. Additional CSS classes.
## Examples
<.page_title text="Your basket" />
<.page_title text="Order History" class="mb-4" />
"""
attr :text, :string, required: true
attr :class, :string, default: "mb-8"
def page_title(assigns) do
~H"""
<h1 class={"text-3xl md:text-4xl font-bold #{@class}"} style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
<%= @text %>
</h1>
"""
end
@doc """
Renders a cart items list with order summary layout.
## Attributes
* `items` - Required. List of cart items.
* `subtotal` - Required. Subtotal in pence/cents.
* `currency` - Optional. Currency symbol. Defaults to "£".
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.cart_layout items={@cart_items} subtotal={3600} mode={:preview} />
"""
attr :items, :list, required: true
attr :subtotal, :integer, required: true
attr :currency, :string, default: "£"
attr :mode, :atom, default: :live
def cart_layout(assigns) do
~H"""
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<div class="space-y-4">
<%= for item <- @items do %>
<.cart_item item={item} currency={@currency} />
<% end %>
</div>
</div>
<div>
<.order_summary subtotal={@subtotal} mode={@mode} />
</div>
</div>
"""
end
@doc """
Renders rich text content with themed typography.
This component renders structured content blocks (paragraphs, headings)
with appropriate theme styling.
## Attributes
* `blocks` - Required. List of content blocks. Each block is a map with:
- `type` - Either `:paragraph`, `:heading`, or `:lead`
- `text` - The text content
- `level` - For headings, the level (2, 3, etc.). Defaults to 2.
## Examples
<.rich_text blocks={[
%{type: :lead, text: "Introduction paragraph..."},
%{type: :paragraph, text: "Regular paragraph..."},
%{type: :heading, text: "Section Title"},
%{type: :paragraph, text: "More content..."}
]} />
"""
attr :blocks, :list, required: true
def rich_text(assigns) do
~H"""
<div class="rich-text" style="line-height: 1.7;">
<%= for block <- @blocks do %>
<.rich_text_block block={block} />
<% end %>
</div>
"""
end
attr :block, :map, required: true
defp rich_text_block(%{block: %{type: :lead}} = assigns) do
~H"""
<p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);">
<%= @block.text %>
</p>
"""
end
defp rich_text_block(%{block: %{type: :paragraph}} = assigns) do
~H"""
<p class="mb-4" style="color: var(--t-text-secondary);">
<%= @block.text %>
</p>
"""
end
defp rich_text_block(%{block: %{type: :heading}} = assigns) do
~H"""
<h2 class="mt-8 mb-3" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-xl); color: var(--t-text-primary);">
<%= @block.text %>
</h2>
"""
end
defp rich_text_block(%{block: %{type: :closing}} = assigns) do
~H"""
<p class="mt-8" style="color: var(--t-text-secondary);">
<%= @block.text %>
</p>
"""
end
defp rich_text_block(assigns) do
~H"""
<p class="mb-4" style="color: var(--t-text-secondary);">
<%= @block.text %>
</p>
"""
end
end end

View File

@ -1,64 +1,26 @@
<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 /> <.skip_link />
<!-- Announcement Bar -->
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} /> <.announcement_bar theme_settings={@theme_settings} />
<% end %> <% end %>
<!-- Header -->
<.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="about" mode={:preview} cart_count={2} /> <.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="about" mode={:preview} cart_count={2} />
<!-- Content Page -->
<main id="main-content" class="content-page" style="background-color: var(--t-surface-base);"> <main id="main-content" class="content-page" style="background-color: var(--t-surface-base);">
<!-- Hero Section -->
<.hero_section <.hero_section
title="About the studio" title="About the studio"
description="Nature photography, printed with care" description="Nature photography, printed with care"
background={:sunken} background={:sunken}
/> />
<!-- Content Body -->
<.content_body image_url="/mockups/night-sky-blanket-3.jpg"> <.content_body image_url="/mockups/night-sky-blanket-3.jpg">
<p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);"> <.rich_text blocks={SimpleshopTheme.Theme.PreviewData.about_content()} />
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 class="mb-4" style="color: var(--t-text-secondary);">
Every design in this shop comes from my own photography. Whether it's early morning mist over the hills, autumn leaves in the local woods, or the quiet beauty of wildflower meadows, I'm drawn to the peaceful moments that nature offers.
</p>
<p class="mb-4" style="color: var(--t-text-secondary);">
I work with quality print partners to bring these images to life on products you can actually use and enjoy from art prints for your walls to mugs for your morning tea.
</p>
<h2 class="mt-8 mb-3" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-xl); color: var(--t-text-primary);">
Quality you can trust
</h2>
<p class="mb-4" style="color: var(--t-text-secondary);">
I've carefully chosen print partners who share my commitment to quality. Every product is made to order using premium materials and printing techniques that ensure vibrant colours and lasting quality.
</p>
<h2 class="mt-8 mb-3" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-xl); color: var(--t-text-primary);">
Printed sustainably
</h2>
<p class="mb-4" style="color: var(--t-text-secondary);">
Because each item is printed on demand, there's no waste from unsold stock. My print partners use eco-friendly inks where possible, and products are shipped directly to you to minimise unnecessary handling.
</p>
<p class="mt-8" style="color: var(--t-text-secondary);">
Thank you for visiting. It means a lot that you're here.
</p>
</.content_body> </.content_body>
</main> </main>
<!-- Footer -->
<.shop_footer theme_settings={@theme_settings} mode={:preview} /> <.shop_footer theme_settings={@theme_settings} mode={:preview} />
<!-- Search Modal -->
<!-- Cart Drawer -->
<.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} /> <.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div> </div>

View File

@ -2,45 +2,21 @@
subtotal = Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end) 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 /> <.skip_link />
<!-- Announcement Bar -->
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} /> <.announcement_bar theme_settings={@theme_settings} />
<% end %> <% end %>
<!-- Header -->
<.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="cart" mode={:preview} cart_count={2} /> <.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="cart" mode={:preview} cart_count={2} />
<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">
<h1 class="text-3xl md:text-4xl font-bold mb-8" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);"> <.page_title text="Your basket" />
Your basket <.cart_layout items={@preview_data.cart_items} subtotal={subtotal} mode={:preview} />
</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Cart Items -->
<div class="lg:col-span-2">
<div class="space-y-4">
<%= for item <- @preview_data.cart_items do %>
<.cart_item item={item} currency="$" />
<% end %>
</div>
</div>
<!-- Order Summary -->
<div>
<.order_summary subtotal={subtotal} mode={:preview} />
</div>
</div>
</main> </main>
<!-- Footer -->
<.shop_footer theme_settings={@theme_settings} mode={:preview} /> <.shop_footer theme_settings={@theme_settings} mode={:preview} />
<!-- Search Modal -->
<!-- Cart Drawer -->
<.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} /> <.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div> </div>

View File

@ -1,20 +1,17 @@
<% <%
product = List.first(@preview_data.products) product = List.first(@preview_data.products)
gallery_images = [product.image_url, product.hover_image_url, product.image_url, product.hover_image_url]
%> %>
<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 /> <.skip_link />
<!-- Announcement Bar -->
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} /> <.announcement_bar theme_settings={@theme_settings} />
<% end %> <% end %>
<!-- Header -->
<.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="pdp" mode={:preview} cart_count={2} /> <.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="pdp" mode={:preview} cart_count={2} />
<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 items={[ <.breadcrumb items={[
%{label: "Home", page: "home", href: "/"}, %{label: "Home", page: "home", href: "/"},
%{label: product.category, page: "collection", href: "/products"}, %{label: product.category, page: "collection", href: "/products"},
@ -22,393 +19,30 @@
]} mode={:preview} /> ]} mode={:preview} />
<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_gallery images={gallery_images} product_name={product.name} />
<div> <div>
<% gallery_images = [product.image_url, product.hover_image_url, product.image_url, product.hover_image_url] %> <.product_info product={product} />
<div <.variant_selector label="Size" options={["S", "M", "L", "XL"]} />
class="pdp-main-image-container aspect-square bg-gray-200 mb-4 overflow-hidden relative" <.quantity_selector quantity={1} in_stock={product.in_stock} />
style="border-radius: var(--t-radius-image);" <.add_to_cart_button />
phx-click={Phoenix.LiveView.JS.exec("data-show", to: "#pdp-lightbox")} <.trust_badges :if={@theme_settings.pdp_trust_badges} />
> <.product_details product={product} />
<img
id="pdp-main-image"
src={product.image_url}
alt={product.name}
class="w-full h-full object-cover"
/>
<!-- Zoom icon overlay -->
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/10">
<div class="w-12 h-12 bg-white/90 rounded-full flex items-center justify-center shadow-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<%= for {img_url, idx} <- Enum.with_index(gallery_images) do %>
<button
type="button"
class={"aspect-square bg-gray-200 cursor-pointer overflow-hidden pdp-thumbnail#{if idx == 0, do: " pdp-thumbnail-active", else: ""}"}
style="border-radius: var(--t-radius-image);"
data-index={idx}
phx-click={Phoenix.LiveView.JS.set_attribute({"src", img_url}, to: "#pdp-main-image")
|> Phoenix.LiveView.JS.set_attribute({"data-current-index", to_string(idx)}, to: "#pdp-lightbox")
|> Phoenix.LiveView.JS.remove_class("pdp-thumbnail-active", to: ".pdp-thumbnail")
|> Phoenix.LiveView.JS.add_class("pdp-thumbnail-active")}
>
<img
src={img_url}
alt={product.name}
class="w-full h-full object-cover"
/>
</button>
<% end %>
</div>
<style>
.pdp-thumbnail {
border: 2px solid var(--t-border-default);
transition: border-color 0.15s ease;
}
.pdp-thumbnail-active {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
}
</style>
<!-- Image Lightbox -->
<dialog
class="lightbox"
id="pdp-lightbox"
aria-label="Product image gallery"
data-current-index="0"
data-images={Jason.encode!(gallery_images)}
data-show={Phoenix.LiveView.JS.exec("phx-show-lightbox", to: "#pdp-lightbox")}
phx-show-lightbox={Phoenix.LiveView.JS.dispatch("pdp:open-lightbox", to: "#pdp-lightbox")}
phx-hook="Lightbox"
>
<div class="lightbox-content">
<button
type="button"
class="lightbox-close"
aria-label="Close gallery"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:close-lightbox", to: "#pdp-lightbox")}
>
<svg 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>
<button
type="button"
class="lightbox-nav lightbox-prev"
aria-label="Previous image"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:prev-image", to: "#pdp-lightbox")}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<figure class="lightbox-figure">
<div class="lightbox-image-container">
<img
class="lightbox-image"
id="lightbox-image"
src={product.image_url}
alt={product.name}
/>
</div>
<figcaption class="lightbox-caption"><%= product.name %></figcaption>
</figure>
<button
type="button"
class="lightbox-nav lightbox-next"
aria-label="Next image"
phx-click={Phoenix.LiveView.JS.dispatch("pdp:next-image", to: "#pdp-lightbox")}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div class="lightbox-counter" id="lightbox-counter">1 / <%= length(gallery_images) %></div>
</div>
</dialog>
</div>
<!-- Product Info -->
<div>
<h1 class="text-3xl md:text-4xl font-bold mb-4" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
<%= product.name %>
</h1>
<div class="flex items-center gap-4 mb-6">
<%= if product.on_sale do %>
<span class="text-3xl font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
£<%= product.price / 100 %>
</span>
<span class="text-xl line-through" style="color: var(--t-text-tertiary);">
£<%= product.compare_at_price / 100 %>
</span>
<span class="px-2 py-1 text-sm font-bold text-white rounded" style="background-color: var(--t-sale-color);">
SAVE <%= round((product.compare_at_price - product.price) / product.compare_at_price * 100) %>%
</span>
<% else %>
<span class="text-3xl font-bold" style="color: var(--t-text-primary);">
£<%= product.price / 100 %>
</span>
<% end %>
</div>
<!-- Variant Options -->
<div class="mb-6">
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
Size
</label>
<div class="flex gap-2">
<%= for size <- ["S", "M", "L", "XL"] do %>
<button
class="px-4 py-2 font-medium transition-all"
style="border: 2px solid var(--t-border-default); border-radius: var(--t-radius-button); color: var(--t-text-primary);"
>
<%= size %>
</button>
<% end %>
</div>
</div>
<!-- Quantity -->
<div class="mb-8">
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
Quantity
</label>
<div class="flex items-center gap-4">
<div class="flex items-center" style="border: 2px solid var(--t-border-default); border-radius: var(--t-radius-input);">
<button class="px-4 py-2" style="color: var(--t-text-primary);"></button>
<span class="px-4 py-2 border-x-2" style="border-color: var(--t-border-default); color: var(--t-text-primary);">
1
</span>
<button class="px-4 py-2" style="color: var(--t-text-primary);">+</button>
</div>
<%= if product.in_stock do %>
<span class="text-sm" style="color: var(--t-text-tertiary);">In stock</span>
<% else %>
<span class="text-sm font-semibold" style="color: var(--t-sale-color);">Out of stock</span>
<% end %>
</div>
</div>
<!-- Add to Cart - Sticky on mobile -->
<div class="sticky bottom-0 z-10 py-3 md:relative md:py-0 -mx-4 px-4 md:mx-0 md:px-0 border-t md:border-0 mb-4" style="background-color: var(--t-surface-base); border-color: var(--t-border-subtle);">
<button
phx-click={Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer") |> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay")}
class="w-full px-6 py-4 text-lg 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); cursor: pointer; border: none;"
>
Add to basket
</button>
</div>
<!-- Features / Trust Badges -->
<%= if @theme_settings.pdp_trust_badges do %>
<div class="p-4 space-y-3" style="background-color: var(--t-surface-sunken); border-radius: var(--t-radius-card);">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<div>
<p class="font-semibold" style="color: var(--t-text-primary);">Free Delivery</p>
<p class="text-sm" style="color: var(--t-text-secondary);">On orders over £40</p>
</div>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="font-semibold" style="color: var(--t-text-primary);">Easy Returns</p>
<p class="text-sm" style="color: var(--t-text-secondary);">30-day return policy</p>
</div>
</div>
</div>
<% end %>
<!-- Product Info Accordion -->
<div class="mt-8 divide-y" style="border-top: 1px solid var(--t-border-subtle); border-bottom: 1px solid var(--t-border-subtle); border-color: var(--t-border-subtle);">
<!-- Description - open by default -->
<details open class="group">
<summary class="flex justify-between items-center py-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<span class="font-semibold">Description</span>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-4 leading-relaxed" style="color: var(--t-text-secondary);">
<p><%= product.description %>. Crafted with attention to detail and quality materials, this product is designed to last. Perfect for everyday use or special occasions.</p>
</div>
</details>
<!-- Size Guide - collapsed by default -->
<details class="group">
<summary class="flex justify-between items-center py-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<span class="font-semibold">Size Guide</span>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-4" style="color: var(--t-text-secondary);">
<table class="w-full text-sm">
<thead>
<tr style="border-bottom: 1px solid var(--t-border-subtle);">
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Size</th>
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Chest (cm)</th>
<th class="text-left py-2 font-semibold" style="color: var(--t-text-primary);">Length (cm)</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid var(--t-border-subtle);">
<td class="py-2">S</td>
<td class="py-2">86-91</td>
<td class="py-2">71</td>
</tr>
<tr style="border-bottom: 1px solid var(--t-border-subtle);">
<td class="py-2">M</td>
<td class="py-2">91-96</td>
<td class="py-2">73</td>
</tr>
<tr style="border-bottom: 1px solid var(--t-border-subtle);">
<td class="py-2">L</td>
<td class="py-2">96-101</td>
<td class="py-2">75</td>
</tr>
<tr>
<td class="py-2">XL</td>
<td class="py-2">101-106</td>
<td class="py-2">77</td>
</tr>
</tbody>
</table>
</div>
</details>
<!-- Shipping & Returns - collapsed by default -->
<details class="group">
<summary class="flex justify-between items-center py-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<span class="font-semibold">Shipping & Returns</span>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-4 space-y-3" style="color: var(--t-text-secondary);">
<div>
<p class="font-semibold mb-1" style="color: var(--t-text-primary);">Delivery</p>
<p class="text-sm">Free UK delivery on orders over £40. Standard delivery 3-5 working days. Express delivery available at checkout.</p>
</div>
<div>
<p class="font-semibold mb-1" style="color: var(--t-text-primary);">Returns</p>
<p class="text-sm">We offer a 30-day return policy. Items must be unused and in original packaging. Please contact us to arrange a return.</p>
</div>
</div>
</details>
</div>
</div> </div>
</div> </div>
<!-- Reviews Section - Accordion format, open by default for social proof --> <.reviews_section :if={@theme_settings.pdp_reviews} reviews={SimpleshopTheme.Theme.PreviewData.reviews()} average_rating={5} total_count={24} />
<%= if @theme_settings.pdp_reviews do %>
<details open class="pdp-reviews group" style="border-top: 1px solid var(--t-border-default);">
<summary class="flex justify-between items-center py-6 cursor-pointer list-none [&::-webkit-details-marker]:hidden" style="color: var(--t-text-primary);">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<h2 class="text-2xl font-bold" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
Customer reviews
</h2>
<div class="flex items-center gap-2">
<div class="flex gap-0.5">
<%= for _i <- 1..5 do %>
<svg class="w-4 h-4" viewBox="0 0 20 20" style="color: #f59e0b;">
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" fill="currentColor"/>
</svg>
<% end %>
</div>
<span class="text-sm" style="color: var(--t-text-secondary);">(24)</span>
</div>
</div>
<svg class="w-5 h-5 transition-transform duration-200 group-open:rotate-180 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pb-8"> <.related_products_section
<div class="space-y-6"> :if={@theme_settings.pdp_related_products}
<!-- Review 1 --> products={Enum.slice(@preview_data.products, 1, 4)}
<article class="pb-6" style="border-bottom: 1px solid var(--t-border-subtle);"> theme_settings={@theme_settings}
<div class="flex items-center justify-between mb-2"> mode={:preview}
<div class="flex gap-0.5"> />
<%= for _i <- 1..5 do %>
<svg class="w-4 h-4" viewBox="0 0 20 20" style="color: #f59e0b;">
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" fill="currentColor"/>
</svg>
<% end %>
</div>
<span class="text-xs" style="color: var(--t-text-tertiary);">2 weeks ago</span>
</div>
<h3 class="font-semibold mb-1" style="color: var(--t-text-primary);">Absolutely beautiful</h3>
<p class="text-sm mb-3" style="color: var(--t-text-secondary); line-height: 1.6;">
The quality exceeded my expectations. The colours are vibrant and the paper feels premium. It's now pride of place in my living room.
</p>
<div class="flex items-center gap-2">
<span class="text-sm font-medium" style="color: var(--t-text-primary);">Sarah M.</span>
<span class="text-xs px-2 py-0.5 rounded" style="background-color: var(--t-surface-sunken); color: var(--t-text-tertiary);">Verified purchase</span>
</div>
</article>
<!-- Review 2 -->
<article class="pb-6" style="border-bottom: 1px solid var(--t-border-subtle);">
<div class="flex items-center justify-between mb-2">
<div class="flex gap-0.5">
<%= for i <- 1..5 do %>
<svg class="w-4 h-4" viewBox="0 0 20 20" style={"color: #{if i <= 4, do: "#f59e0b", else: "var(--t-border-default)"};"}>
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" fill="currentColor"/>
</svg>
<% end %>
</div>
<span class="text-xs" style="color: var(--t-text-tertiary);">1 month ago</span>
</div>
<h3 class="font-semibold mb-1" style="color: var(--t-text-primary);">Great gift</h3>
<p class="text-sm mb-3" style="color: var(--t-text-secondary); line-height: 1.6;">
Bought this as a gift and it arrived beautifully packaged. Fast shipping too. Would definitely order again.
</p>
<div class="flex items-center gap-2">
<span class="text-sm font-medium" style="color: var(--t-text-primary);">James T.</span>
<span class="text-xs px-2 py-0.5 rounded" style="background-color: var(--t-surface-sunken); color: var(--t-text-tertiary);">Verified purchase</span>
</div>
</article>
</div>
<button
class="mt-6 px-6 py-2 text-sm font-medium transition-all mx-auto block"
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-button);"
>
Load more reviews
</button>
</div>
</details>
<% end %>
<!-- Related Products -->
<%= if @theme_settings.pdp_related_products do %>
<.related_products_section
products={Enum.slice(@preview_data.products, 1, 4)}
theme_settings={@theme_settings}
mode={:preview}
/>
<% end %>
</main> </main>
<!-- Footer -->
<.shop_footer theme_settings={@theme_settings} mode={:preview} /> <.shop_footer theme_settings={@theme_settings} mode={:preview} />
<!-- Search Modal -->
<!-- Cart Drawer -->
<.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} /> <.cart_drawer cart_items={SimpleshopTheme.Theme.PreviewData.cart_drawer_items()} subtotal="£72.00" mode={:preview} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div> </div>