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:
parent
0c15929c19
commit
97981a9884
@ -57,6 +57,50 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
||||
mock_testimonials()
|
||||
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 """
|
||||
Returns categories for preview.
|
||||
|
||||
|
||||
@ -1885,4 +1885,754 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
|
||||
@ -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);">
|
||||
<!-- Skip Link for Accessibility -->
|
||||
<.skip_link />
|
||||
|
||||
<!-- Announcement Bar -->
|
||||
<%= if @theme_settings.announcement_bar do %>
|
||||
<.announcement_bar theme_settings={@theme_settings} />
|
||||
<% end %>
|
||||
|
||||
<!-- Header -->
|
||||
<.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);">
|
||||
<!-- Hero Section -->
|
||||
<.hero_section
|
||||
title="About the studio"
|
||||
description="Nature photography, printed with care"
|
||||
background={:sunken}
|
||||
/>
|
||||
|
||||
<!-- Content Body -->
|
||||
<.content_body image_url="/mockups/night-sky-blanket-3.jpg">
|
||||
<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.
|
||||
</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>
|
||||
<.rich_text blocks={SimpleshopTheme.Theme.PreviewData.about_content()} />
|
||||
</.content_body>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<.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} />
|
||||
|
||||
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
|
||||
</div>
|
||||
|
||||
@ -2,45 +2,21 @@
|
||||
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);">
|
||||
<!-- Skip Link for Accessibility -->
|
||||
<.skip_link />
|
||||
|
||||
<!-- Announcement Bar -->
|
||||
<%= if @theme_settings.announcement_bar do %>
|
||||
<.announcement_bar theme_settings={@theme_settings} />
|
||||
<% end %>
|
||||
|
||||
<!-- Header -->
|
||||
<.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">
|
||||
<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);">
|
||||
Your basket
|
||||
</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>
|
||||
<.page_title text="Your basket" />
|
||||
<.cart_layout items={@preview_data.cart_items} subtotal={subtotal} mode={:preview} />
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<.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} />
|
||||
|
||||
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
|
||||
</div>
|
||||
|
||||
@ -1,20 +1,17 @@
|
||||
<%
|
||||
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);">
|
||||
<!-- Skip Link for Accessibility -->
|
||||
<.skip_link />
|
||||
|
||||
<!-- Announcement Bar -->
|
||||
<%= if @theme_settings.announcement_bar do %>
|
||||
<.announcement_bar theme_settings={@theme_settings} />
|
||||
<% end %>
|
||||
|
||||
<!-- Header -->
|
||||
<.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">
|
||||
<!-- Breadcrumb -->
|
||||
<.breadcrumb items={[
|
||||
%{label: "Home", page: "home", href: "/"},
|
||||
%{label: product.category, page: "collection", href: "/products"},
|
||||
@ -22,393 +19,30 @@
|
||||
]} mode={:preview} />
|
||||
|
||||
<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>
|
||||
<% gallery_images = [product.image_url, product.hover_image_url, product.image_url, product.hover_image_url] %>
|
||||
<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: "#pdp-lightbox")}
|
||||
>
|
||||
<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>
|
||||
<.product_info product={product} />
|
||||
<.variant_selector label="Size" options={["S", "M", "L", "XL"]} />
|
||||
<.quantity_selector quantity={1} in_stock={product.in_stock} />
|
||||
<.add_to_cart_button />
|
||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||
<.product_details product={product} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section - Accordion format, open by default for social proof -->
|
||||
<%= 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>
|
||||
<.reviews_section :if={@theme_settings.pdp_reviews} reviews={SimpleshopTheme.Theme.PreviewData.reviews()} average_rating={5} total_count={24} />
|
||||
|
||||
<div class="pb-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Review 1 -->
|
||||
<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: #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 %>
|
||||
<.related_products_section
|
||||
:if={@theme_settings.pdp_related_products}
|
||||
products={Enum.slice(@preview_data.products, 1, 4)}
|
||||
theme_settings={@theme_settings}
|
||||
mode={:preview}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<.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} />
|
||||
|
||||
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user