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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user