feat: add Priority 3 product detail e-commerce macros

New macros in shared/macros/shop/:
- product-gallery.html: Image gallery with thumbnails, zoom, lightbox
- variant-selector.html: Size buttons, color swatches, multi-variant
- product-info.html: Product details with price, rating, stock, badges
- product-tabs.html: Tabbed content (description, specs, reviews)

All components support:
- Dark mode via Tailwind dark: classes
- Alpine.js integration for reactivity
- Responsive design
- Accessible markup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 17:14:27 +01:00
parent 353da1581a
commit 487bbc562a
4 changed files with 1575 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
{#
Product Gallery Components
==========================
Image gallery with thumbnails, zoom, and lightbox for product detail pages.
Usage:
{% from 'shared/macros/shop/product-gallery.html' import product_gallery, gallery_thumbnails %}
#}
{#
Product Gallery
===============
Full image gallery with main image and thumbnails.
Parameters:
- images_var: Alpine.js expression for images array (default: 'product.images')
- selected_var: Alpine.js variable for selected image index (default: 'selectedImage')
- show_thumbnails: Show thumbnail navigation (default: true)
- enable_zoom: Enable hover zoom on main image (default: true)
- enable_lightbox: Enable fullscreen lightbox (default: true)
- max_thumbnails: Max thumbnails to show (default: 5)
- aspect_ratio: Main image aspect ratio (default: 'square')
Expected image object:
{
id: 1,
url: 'https://...',
thumbnail_url: 'https://...',
alt: 'Product image'
}
Usage:
{{ product_gallery(images_var='product.images') }}
#}
{% macro product_gallery(
images_var='product.images',
selected_var='selectedImage',
show_thumbnails=true,
enable_zoom=true,
enable_lightbox=true,
max_thumbnails=5,
aspect_ratio='square'
) %}
{% set aspects = {
'square': 'aspect-square',
'4:3': 'aspect-[4/3]',
'3:4': 'aspect-[3/4]',
'16:9': 'aspect-video',
'3:2': 'aspect-[3/2]'
} %}
{% set aspect_class = aspects.get(aspect_ratio, 'aspect-square') %}
<div
x-data="{
{{ selected_var }}: 0,
showLightbox: false,
zoomActive: false,
zoomX: 50,
zoomY: 50,
get images() { return {{ images_var }} || [] },
get currentImage() { return this.images[this.{{ selected_var }}] || {} },
next() {
if (this.{{ selected_var }} < this.images.length - 1) {
this.{{ selected_var }}++;
} else {
this.{{ selected_var }} = 0;
}
},
prev() {
if (this.{{ selected_var }} > 0) {
this.{{ selected_var }}--;
} else {
this.{{ selected_var }} = this.images.length - 1;
}
},
handleZoom(e) {
if (!{{ 'true' if enable_zoom else 'false' }}) return;
const rect = e.target.getBoundingClientRect();
this.zoomX = ((e.clientX - rect.left) / rect.width) * 100;
this.zoomY = ((e.clientY - rect.top) / rect.height) * 100;
}
}"
class="space-y-4"
>
{# Main Image Container #}
<div class="relative group">
{# Main Image #}
<div
class="relative {{ aspect_class }} bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden cursor-crosshair"
{% if enable_zoom %}
@mouseenter="zoomActive = true"
@mouseleave="zoomActive = false"
@mousemove="handleZoom($event)"
{% endif %}
{% if enable_lightbox %}
@click="showLightbox = true"
{% endif %}
>
{# Product Image #}
<img
:src="currentImage.url || currentImage.image_url || '/static/shared/images/placeholder.png'"
:alt="currentImage.alt || 'Product image'"
class="absolute inset-0 w-full h-full object-contain transition-transform duration-200"
:class="zoomActive && 'scale-150'"
:style="zoomActive ? `transform-origin: ${zoomX}% ${zoomY}%` : ''"
/>
{# Zoom Indicator #}
{% if enable_zoom %}
<div
x-show="!zoomActive"
class="absolute bottom-3 right-3 bg-black/50 text-white text-xs px-2 py-1 rounded flex items-center gap-1"
>
<span x-html="$icon('zoom-in', 'w-4 h-4')"></span>
<span>Hover to zoom</span>
</div>
{% endif %}
{# Lightbox Button #}
{% if enable_lightbox %}
<button
type="button"
@click.stop="showLightbox = true"
class="absolute top-3 right-3 p-2 bg-white/80 dark:bg-gray-800/80 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity"
title="View fullscreen"
>
<span x-html="$icon('arrows-expand', 'w-5 h-5 text-gray-700 dark:text-gray-300')"></span>
</button>
{% endif %}
</div>
{# Navigation Arrows #}
<template x-if="images.length > 1">
<div>
<button
type="button"
@click="prev()"
class="absolute left-2 top-1/2 -translate-y-1/2 p-2 bg-white/80 dark:bg-gray-800/80 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-800"
>
<span x-html="$icon('chevron-left', 'w-5 h-5 text-gray-700 dark:text-gray-300')"></span>
</button>
<button
type="button"
@click="next()"
class="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-white/80 dark:bg-gray-800/80 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-800"
>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-700 dark:text-gray-300')"></span>
</button>
</div>
</template>
{# Image Counter #}
<template x-if="images.length > 1">
<div class="absolute bottom-3 left-3 bg-black/50 text-white text-xs px-2 py-1 rounded">
<span x-text="({{ selected_var }} + 1) + ' / ' + images.length"></span>
</div>
</template>
</div>
{# Thumbnails #}
{% if show_thumbnails %}
<div x-show="images.length > 1" class="relative">
<div class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
<template x-for="(image, index) in images.slice(0, {{ max_thumbnails }})" :key="image.id || index">
<button
type="button"
@click="{{ selected_var }} = index"
class="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all"
:class="{{ selected_var }} === index
? 'border-purple-500 dark:border-purple-400 ring-2 ring-purple-500/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'"
>
<img
:src="image.thumbnail_url || image.url || image.image_url"
:alt="image.alt || 'Thumbnail ' + (index + 1)"
class="w-full h-full object-cover"
/>
</button>
</template>
{# More Images Indicator #}
<template x-if="images.length > {{ max_thumbnails }}">
<button
type="button"
@click="showLightbox = true"
class="flex-shrink-0 w-16 h-16 rounded-lg bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<span class="text-sm font-medium text-gray-600 dark:text-gray-400" x-text="'+' + (images.length - {{ max_thumbnails }})"></span>
</button>
</template>
</div>
</div>
{% endif %}
{# Lightbox Modal #}
{% if enable_lightbox %}
<div
x-show="showLightbox"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="showLightbox = false"
@keydown.arrow-left.window="showLightbox && prev()"
@keydown.arrow-right.window="showLightbox && next()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
style="display: none;"
>
{# Close Button #}
<button
type="button"
@click="showLightbox = false"
class="absolute top-4 right-4 p-2 text-white hover:text-gray-300 transition-colors"
>
<span x-html="$icon('x', 'w-8 h-8')"></span>
</button>
{# Lightbox Image #}
<div class="relative max-w-5xl max-h-[90vh] mx-4">
<img
:src="currentImage.url || currentImage.image_url"
:alt="currentImage.alt || 'Product image'"
class="max-w-full max-h-[90vh] object-contain"
/>
</div>
{# Lightbox Navigation #}
<template x-if="images.length > 1">
<div>
<button
type="button"
@click="prev()"
class="absolute left-4 top-1/2 -translate-y-1/2 p-3 text-white hover:text-gray-300 transition-colors"
>
<span x-html="$icon('chevron-left', 'w-10 h-10')"></span>
</button>
<button
type="button"
@click="next()"
class="absolute right-4 top-1/2 -translate-y-1/2 p-3 text-white hover:text-gray-300 transition-colors"
>
<span x-html="$icon('chevron-right', 'w-10 h-10')"></span>
</button>
</div>
</template>
{# Lightbox Thumbnails #}
<div x-show="images.length > 1" class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
<template x-for="(image, index) in images" :key="image.id || index">
<button
type="button"
@click="{{ selected_var }} = index"
class="w-12 h-12 rounded overflow-hidden border-2 transition-all"
:class="{{ selected_var }} === index
? 'border-white'
: 'border-transparent opacity-60 hover:opacity-100'"
>
<img
:src="image.thumbnail_url || image.url || image.image_url"
:alt="image.alt || 'Thumbnail'"
class="w-full h-full object-cover"
/>
</button>
</template>
</div>
{# Image Counter #}
<div class="absolute top-4 left-4 text-white text-sm">
<span x-text="({{ selected_var }} + 1) + ' / ' + images.length"></span>
</div>
</div>
{% endif %}
</div>
{% endmacro %}
{#
Gallery Thumbnails Only
=======================
Standalone thumbnail strip for custom layouts.
Parameters:
- images_var: Alpine.js expression for images array
- selected_var: Alpine.js variable for selected index
- orientation: 'horizontal' | 'vertical' (default: 'horizontal')
- size: 'sm' | 'md' | 'lg' (default: 'md')
Usage:
{{ gallery_thumbnails(images_var='product.images', orientation='vertical') }}
#}
{% macro gallery_thumbnails(
images_var='product.images',
selected_var='selectedImage',
orientation='horizontal',
size='md'
) %}
{% set sizes = {
'sm': {'container': 'w-12 h-12', 'gap': 'gap-1'},
'md': {'container': 'w-16 h-16', 'gap': 'gap-2'},
'lg': {'container': 'w-20 h-20', 'gap': 'gap-3'}
} %}
{% set s = sizes[size] %}
{% set is_vertical = orientation == 'vertical' %}
<div class="flex {{ 'flex-col' if is_vertical else 'flex-row' }} {{ s.gap }} {{ 'overflow-y-auto' if is_vertical else 'overflow-x-auto' }}">
<template x-for="(image, index) in {{ images_var }}" :key="image.id || index">
<button
type="button"
@click="{{ selected_var }} = index"
class="flex-shrink-0 {{ s.container }} rounded-lg overflow-hidden border-2 transition-all"
:class="{{ selected_var }} === index
? 'border-purple-500 dark:border-purple-400'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'"
>
<img
:src="image.thumbnail_url || image.url || image.image_url"
:alt="image.alt || 'Thumbnail ' + (index + 1)"
class="w-full h-full object-cover"
/>
</button>
</template>
</div>
{% endmacro %}
{#
Simple Image Viewer
===================
Single image with optional lightbox (for simple product pages).
Parameters:
- image_var: Alpine.js expression for image object or URL string
- enable_lightbox: Enable click to enlarge (default: true)
- aspect_ratio: Image aspect ratio (default: 'square')
Usage:
{{ simple_image_viewer(image_var='product.image_url') }}
#}
{% macro simple_image_viewer(
image_var='product.image_url',
enable_lightbox=true,
aspect_ratio='square'
) %}
{% set aspects = {
'square': 'aspect-square',
'4:3': 'aspect-[4/3]',
'3:4': 'aspect-[3/4]',
'16:9': 'aspect-video'
} %}
{% set aspect_class = aspects.get(aspect_ratio, 'aspect-square') %}
<div x-data="{ showLightbox: false }">
<div
class="relative {{ aspect_class }} bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden {{ 'cursor-pointer' if enable_lightbox else '' }}"
{% if enable_lightbox %}@click="showLightbox = true"{% endif %}
>
<img
:src="typeof {{ image_var }} === 'string' ? {{ image_var }} : ({{ image_var }}.url || {{ image_var }}.image_url || '/static/shared/images/placeholder.png')"
:alt="typeof {{ image_var }} === 'object' ? {{ image_var }}.alt : 'Product image'"
class="absolute inset-0 w-full h-full object-contain"
/>
{% if enable_lightbox %}
<div class="absolute inset-0 bg-black/0 hover:bg-black/10 transition-colors flex items-center justify-center">
<span class="opacity-0 hover:opacity-100 transition-opacity" x-html="$icon('zoom-in', 'w-8 h-8 text-white drop-shadow-lg')"></span>
</div>
{% endif %}
</div>
{% if enable_lightbox %}
{# Lightbox #}
<div
x-show="showLightbox"
x-transition
@click="showLightbox = false"
@keydown.escape.window="showLightbox = false"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
style="display: none;"
>
<button
type="button"
@click="showLightbox = false"
class="absolute top-4 right-4 p-2 text-white hover:text-gray-300"
>
<span x-html="$icon('x', 'w-8 h-8')"></span>
</button>
<img
:src="typeof {{ image_var }} === 'string' ? {{ image_var }} : ({{ image_var }}.url || {{ image_var }}.image_url)"
:alt="typeof {{ image_var }} === 'object' ? {{ image_var }}.alt : 'Product image'"
class="max-w-full max-h-[90vh] object-contain"
/>
</div>
{% endif %}
</div>
{% endmacro %}

View File

@@ -0,0 +1,359 @@
{#
Product Info Components
=======================
Product details section for product detail pages.
Usage:
{% from 'shared/macros/shop/product-info.html' import product_info, product_price, product_rating, stock_status %}
#}
{#
Product Info Block
==================
Complete product information section.
Parameters:
- product_var: Alpine.js expression for product (default: 'product')
- show_sku: Show SKU (default: false)
- show_stock: Show stock status (default: true)
- show_rating: Show star rating (default: true)
- show_vendor: Show vendor name - for marketplace (default: false)
- show_category: Show category breadcrumb (default: false)
- title_tag: HTML tag for title (default: 'h1')
Expected product object:
{
name: 'Product Name',
sku: 'SKU-123',
price: 99.99,
sale_price: 79.99,
rating: 4.5,
review_count: 127,
stock: 15,
short_description: '...',
vendor: { name: 'Vendor Name', url: '/vendor/...' },
category: { name: 'Category', url: '/category/...' }
}
Usage:
{{ product_info(product_var='product', show_vendor=true) }}
#}
{% macro product_info(
product_var='product',
show_sku=false,
show_stock=true,
show_rating=true,
show_vendor=false,
show_category=false,
title_tag='h1'
) %}
<div class="space-y-4">
{# Category / Vendor (if marketplace) #}
{% if show_category or show_vendor %}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
{% if show_category %}
<template x-if="{{ product_var }}.category">
<a
:href="{{ product_var }}.category.url || '/category/' + {{ product_var }}.category.slug"
class="hover:text-purple-600 dark:hover:text-purple-400"
x-text="{{ product_var }}.category.name"
></a>
</template>
{% endif %}
{% if show_category and show_vendor %}
<span x-show="{{ product_var }}.category && {{ product_var }}.vendor">&bull;</span>
{% endif %}
{% if show_vendor %}
<template x-if="{{ product_var }}.vendor">
<a
:href="{{ product_var }}.vendor.url || '/vendor/' + {{ product_var }}.vendor.slug"
class="hover:text-purple-600 dark:hover:text-purple-400"
>
Sold by <span x-text="{{ product_var }}.vendor.name" class="font-medium"></span>
</a>
</template>
{% endif %}
</div>
{% endif %}
{# Product Title #}
<{{ title_tag }}
class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white"
x-text="{{ product_var }}.name"
></{{ title_tag }}>
{# Rating and Review Count #}
{% if show_rating %}
<div x-show="{{ product_var }}.rating" class="flex items-center gap-3">
{{ product_rating(product_var=product_var, size='md', clickable=true) }}
</div>
{% endif %}
{# Price #}
{{ product_price(product_var=product_var, size='lg') }}
{# Short Description #}
<div
x-show="{{ product_var }}.short_description"
class="text-gray-600 dark:text-gray-400 leading-relaxed"
x-html="{{ product_var }}.short_description"
></div>
{# Stock Status #}
{% if show_stock %}
{{ stock_status(product_var=product_var) }}
{% endif %}
{# SKU #}
{% if show_sku %}
<div x-show="{{ product_var }}.sku" class="text-sm text-gray-500 dark:text-gray-500">
SKU: <span x-text="{{ product_var }}.sku" class="font-mono"></span>
</div>
{% endif %}
</div>
{% endmacro %}
{#
Product Price
=============
Price display with sale price support.
Parameters:
- product_var: Alpine.js expression for product
- size: 'sm' | 'md' | 'lg' (default: 'md')
- show_discount: Show discount percentage (default: true)
Usage:
{{ product_price(product_var='product', size='lg') }}
#}
{% macro product_price(
product_var='product',
size='md',
show_discount=true
) %}
{% set sizes = {
'sm': {'price': 'text-lg', 'original': 'text-sm', 'badge': 'text-xs px-1.5 py-0.5'},
'md': {'price': 'text-xl', 'original': 'text-base', 'badge': 'text-xs px-2 py-0.5'},
'lg': {'price': 'text-2xl md:text-3xl', 'original': 'text-lg', 'badge': 'text-sm px-2 py-1'}
} %}
{% set s = sizes[size] %}
<div class="flex items-center flex-wrap gap-2">
{# Current Price (sale or regular) #}
<span
class="{{ s.price }} font-bold"
:class="{{ product_var }}.sale_price ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
x-text="formatCurrency({{ product_var }}.sale_price || {{ product_var }}.price)"
></span>
{# Original Price (if on sale) #}
<span
x-show="{{ product_var }}.sale_price"
class="{{ s.original }} text-gray-500 dark:text-gray-400 line-through"
x-text="formatCurrency({{ product_var }}.price)"
></span>
{# Discount Badge #}
{% if show_discount %}
<span
x-show="{{ product_var }}.sale_price"
class="{{ s.badge }} bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full font-medium"
x-text="'-' + Math.round((1 - {{ product_var }}.sale_price / {{ product_var }}.price) * 100) + '%'"
></span>
{% endif %}
</div>
{% endmacro %}
{#
Product Rating
==============
Star rating display with review count.
Parameters:
- product_var: Alpine.js expression for product
- size: 'sm' | 'md' | 'lg' (default: 'md')
- clickable: Make clickable to scroll to reviews (default: false)
- show_count: Show review count (default: true)
Usage:
{{ product_rating(product_var='product', clickable=true) }}
#}
{% macro product_rating(
product_var='product',
size='md',
clickable=false,
show_count=true
) %}
{% set sizes = {
'sm': {'star': 'w-4 h-4', 'text': 'text-xs'},
'md': {'star': 'w-5 h-5', 'text': 'text-sm'},
'lg': {'star': 'w-6 h-6', 'text': 'text-base'}
} %}
{% set s = sizes[size] %}
<div
class="flex items-center gap-2 {{ 'cursor-pointer hover:opacity-80' if clickable else '' }}"
{% if clickable %}@click="document.getElementById('reviews')?.scrollIntoView({ behavior: 'smooth' })"{% endif %}
>
{# Stars #}
<div class="flex items-center">
<template x-for="i in 5" :key="i">
<span
x-html="$icon('star', '{{ s.star }}')"
:class="i <= Math.round({{ product_var }}.rating || 0)
? 'text-yellow-400 fill-current'
: 'text-gray-300 dark:text-gray-600'"
></span>
</template>
</div>
{# Rating Number #}
<span class="{{ s.text }} font-medium text-gray-700 dark:text-gray-300" x-text="{{ product_var }}.rating?.toFixed(1)"></span>
{# Review Count #}
{% if show_count %}
<span class="{{ s.text }} text-gray-500 dark:text-gray-400">
(<span x-text="{{ product_var }}.review_count || 0"></span> reviews)
</span>
{% endif %}
</div>
{% endmacro %}
{#
Stock Status
============
Stock availability indicator.
Parameters:
- product_var: Alpine.js expression for product (or stock number)
- low_stock_threshold: Show warning below this number (default: 10)
Usage:
{{ stock_status(product_var='product') }}
{{ stock_status(product_var='selectedVariant.stock') }}
#}
{% macro stock_status(
product_var='product',
low_stock_threshold=10
) %}
<div class="flex items-center gap-2">
{# In Stock #}
<template x-if="({{ product_var }}.stock !== undefined ? {{ product_var }}.stock : {{ product_var }}) > {{ low_stock_threshold }}">
<div class="flex items-center gap-1.5 text-green-600 dark:text-green-400">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
<span class="text-sm font-medium">In Stock</span>
</div>
</template>
{# Low Stock #}
<template x-if="({{ product_var }}.stock !== undefined ? {{ product_var }}.stock : {{ product_var }}) > 0 && ({{ product_var }}.stock !== undefined ? {{ product_var }}.stock : {{ product_var }}) <= {{ low_stock_threshold }}">
<div class="flex items-center gap-1.5 text-orange-600 dark:text-orange-400">
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
<span class="text-sm font-medium">
Only <span x-text="{{ product_var }}.stock !== undefined ? {{ product_var }}.stock : {{ product_var }}"></span> left in stock
</span>
</div>
</template>
{# Out of Stock #}
<template x-if="({{ product_var }}.stock !== undefined ? {{ product_var }}.stock : {{ product_var }}) === 0">
<div class="flex items-center gap-1.5 text-red-600 dark:text-red-400">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
<span class="text-sm font-medium">Out of Stock</span>
</div>
</template>
</div>
{% endmacro %}
{#
Product Badges
==============
Display multiple product badges (new, sale, bestseller, etc.).
Parameters:
- product_var: Alpine.js expression for product
Usage:
{{ product_badges(product_var='product') }}
#}
{% macro product_badges(product_var='product') %}
<div class="flex flex-wrap gap-2">
{# Sale Badge #}
<template x-if="{{ product_var }}.sale_price">
<span class="px-2 py-1 text-xs font-bold text-white bg-red-500 rounded">
SALE
</span>
</template>
{# New Badge #}
<template x-if="{{ product_var }}.is_new">
<span class="px-2 py-1 text-xs font-bold text-white bg-green-500 rounded">
NEW
</span>
</template>
{# Bestseller Badge #}
<template x-if="{{ product_var }}.is_bestseller">
<span class="px-2 py-1 text-xs font-bold text-white bg-purple-500 rounded">
BESTSELLER
</span>
</template>
{# Limited Edition Badge #}
<template x-if="{{ product_var }}.is_limited">
<span class="px-2 py-1 text-xs font-bold text-white bg-amber-500 rounded">
LIMITED
</span>
</template>
</div>
{% endmacro %}
{#
Trust Indicators
================
Display trust/shipping information below add to cart.
Parameters:
- show_shipping: Show free shipping info (default: true)
- show_returns: Show returns policy (default: true)
- show_secure: Show secure checkout badge (default: true)
- free_shipping_threshold: Amount for free shipping (default: 50)
Usage:
{{ trust_indicators(free_shipping_threshold=75) }}
#}
{% macro trust_indicators(
show_shipping=true,
show_returns=true,
show_secure=true,
free_shipping_threshold=50
) %}
<div class="flex flex-col gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
{% if show_shipping %}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span x-html="$icon('truck', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
<span>Free shipping on orders over ${{ free_shipping_threshold }}</span>
</div>
{% endif %}
{% if show_returns %}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span x-html="$icon('refresh', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
<span>30-day easy returns</span>
</div>
{% endif %}
{% if show_secure %}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span x-html="$icon('shield-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
<span>Secure checkout</span>
</div>
{% endif %}
</div>
{% endmacro %}

View File

@@ -0,0 +1,400 @@
{#
Product Tabs Components
=======================
Tabbed content sections for product detail pages.
Usage:
{% from 'shared/macros/shop/product-tabs.html' import product_tabs, tab_description, tab_specifications, tab_reviews %}
#}
{#
Product Tabs
============
Tabbed container for product information sections.
Parameters:
- product_var: Alpine.js expression for product (default: 'product')
- tabs: List of tab IDs to show (default: ['description', 'specifications', 'reviews'])
- default_tab: Initially active tab (default: 'description')
- tab_var: Alpine.js variable for active tab (default: 'activeProductTab')
Usage:
{{ product_tabs(tabs=['description', 'specifications', 'reviews', 'shipping']) }}
#}
{% macro product_tabs(
product_var='product',
tabs=['description', 'specifications', 'reviews'],
default_tab='description',
tab_var='activeProductTab'
) %}
{% set tab_config = {
'description': {'label': 'Description', 'icon': 'document-text'},
'specifications': {'label': 'Specifications', 'icon': 'clipboard-list'},
'reviews': {'label': 'Reviews', 'icon': 'star'},
'shipping': {'label': 'Shipping & Returns', 'icon': 'truck'},
'warranty': {'label': 'Warranty', 'icon': 'shield-check'}
} %}
<div x-data="{ {{ tab_var }}: '{{ default_tab }}' }" class="mt-8">
{# Tab Navigation #}
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex gap-4 -mb-px overflow-x-auto" aria-label="Product information tabs">
{% for tab_id in tabs %}
{% set config = tab_config.get(tab_id, {'label': tab_id|capitalize, 'icon': 'document'}) %}
<button
type="button"
@click="{{ tab_var }} = '{{ tab_id }}'"
class="flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
:class="{{ tab_var }} === '{{ tab_id }}'
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:border-gray-300'"
{% if tab_id == 'reviews' %}
:aria-label="'Reviews (' + ({{ product_var }}.review_count || 0) + ')'"
{% endif %}
>
<span x-html="$icon('{{ config.icon }}', 'w-5 h-5')"></span>
<span>{{ config.label }}</span>
{% if tab_id == 'reviews' %}
<span
x-show="{{ product_var }}.review_count"
class="px-2 py-0.5 text-xs rounded-full"
:class="{{ tab_var }} === 'reviews'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'"
x-text="{{ product_var }}.review_count"
></span>
{% endif %}
</button>
{% endfor %}
</nav>
</div>
{# Tab Panels #}
<div class="py-6">
{% if 'description' in tabs %}
{{ tab_description(product_var, tab_var) }}
{% endif %}
{% if 'specifications' in tabs %}
{{ tab_specifications(product_var, tab_var) }}
{% endif %}
{% if 'reviews' in tabs %}
{{ tab_reviews(product_var, tab_var) }}
{% endif %}
{% if 'shipping' in tabs %}
{{ tab_shipping(tab_var) }}
{% endif %}
{% if 'warranty' in tabs %}
{{ tab_warranty(product_var, tab_var) }}
{% endif %}
</div>
</div>
{% endmacro %}
{#
Tab: Description
================
Product description content panel.
#}
{% macro tab_description(product_var='product', tab_var='activeProductTab') %}
<div x-show="{{ tab_var }} === 'description'" x-transition>
<div
class="prose prose-gray dark:prose-invert max-w-none"
x-html="{{ product_var }}.description || {{ product_var }}.full_description || '<p class=\'text-gray-500\'>No description available.</p>'"
></div>
{# Features List (if available) #}
<template x-if="{{ product_var }}.features?.length">
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Key Features</h3>
<ul class="space-y-2">
<template x-for="feature in {{ product_var }}.features" :key="feature">
<li class="flex items-start gap-2">
<span x-html="$icon('check', 'w-5 h-5 text-green-500 flex-shrink-0 mt-0.5')"></span>
<span class="text-gray-600 dark:text-gray-400" x-text="feature"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
{% endmacro %}
{#
Tab: Specifications
===================
Product specifications table panel.
#}
{% macro tab_specifications(product_var='product', tab_var='activeProductTab') %}
<div x-show="{{ tab_var }} === 'specifications'" x-transition>
<template x-if="{{ product_var }}.specifications?.length || {{ product_var }}.specs?.length || {{ product_var }}.attributes?.length">
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="(spec, index) in ({{ product_var }}.specifications || {{ product_var }}.specs || {{ product_var }}.attributes)" :key="spec.name || index">
<tr :class="index % 2 === 0 ? 'bg-gray-50 dark:bg-gray-800/50' : 'bg-white dark:bg-gray-800'">
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white w-1/3" x-text="spec.name || spec.key || spec.label"></td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400" x-text="spec.value"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<template x-if="!({{ product_var }}.specifications?.length || {{ product_var }}.specs?.length || {{ product_var }}.attributes?.length)">
<p class="text-gray-500 dark:text-gray-400">No specifications available.</p>
</template>
</div>
{% endmacro %}
{#
Tab: Reviews
============
Product reviews list and summary panel.
#}
{% macro tab_reviews(product_var='product', tab_var='activeProductTab') %}
<div x-show="{{ tab_var }} === 'reviews'" x-transition id="reviews">
{# Reviews Summary #}
<div class="flex flex-col md:flex-row gap-8 mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
{# Overall Rating #}
<div class="text-center md:text-left">
<div class="text-5xl font-bold text-gray-900 dark:text-white" x-text="({{ product_var }}.rating || 0).toFixed(1)"></div>
<div class="flex items-center justify-center md:justify-start gap-1 mt-2">
<template x-for="i in 5" :key="i">
<span
x-html="$icon('star', 'w-5 h-5')"
:class="i <= Math.round({{ product_var }}.rating || 0)
? 'text-yellow-400 fill-current'
: 'text-gray-300 dark:text-gray-600'"
></span>
</template>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<span x-text="{{ product_var }}.review_count || 0"></span> reviews
</div>
</div>
{# Rating Distribution #}
<template x-if="{{ product_var }}.rating_distribution">
<div class="flex-1 space-y-2">
<template x-for="rating in [5, 4, 3, 2, 1]" :key="rating">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400 w-8" x-text="rating + '★'"></span>
<div class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-yellow-400 rounded-full"
:style="'width: ' + ({{ product_var }}.rating_distribution?.[rating] || 0) + '%'"
></div>
</div>
<span
class="text-sm text-gray-500 dark:text-gray-400 w-10 text-right"
x-text="({{ product_var }}.rating_distribution?.[rating] || 0) + '%'"
></span>
</div>
</template>
</div>
</template>
</div>
{# Reviews List #}
<template x-if="{{ product_var }}.reviews?.length">
<div class="space-y-6">
<template x-for="review in {{ product_var }}.reviews" :key="review.id">
{{ review_card() }}
</template>
{# Load More Reviews #}
<template x-if="{{ product_var }}.reviews?.length < {{ product_var }}.review_count">
<div class="text-center pt-4">
<button
type="button"
class="px-6 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-700 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors"
>
Load More Reviews
</button>
</div>
</template>
</div>
</template>
{# No Reviews #}
<template x-if="!{{ product_var }}.reviews?.length">
<div class="text-center py-8">
<span x-html="$icon('chat-alt-2', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No reviews yet</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">Be the first to review this product!</p>
<button
type="button"
class="mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
Write a Review
</button>
</div>
</template>
</div>
{% endmacro %}
{#
Review Card
===========
Individual review display.
#}
{% macro review_card() %}
<div class="border-b border-gray-200 dark:border-gray-700 pb-6 last:border-0">
{# Header #}
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
{# Avatar #}
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<span
class="text-sm font-medium text-purple-600 dark:text-purple-400"
x-text="(review.author_name || review.author || 'A').charAt(0).toUpperCase()"
></span>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white" x-text="review.author_name || review.author || 'Anonymous'"></span>
<template x-if="review.verified">
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 rounded">
<span x-html="$icon('badge-check', 'w-3 h-3')"></span>
Verified
</span>
</template>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="formatDate(review.created_at || review.date)"></div>
</div>
</div>
{# Rating #}
<div class="flex items-center">
<template x-for="i in 5" :key="i">
<span
x-html="$icon('star', 'w-4 h-4')"
:class="i <= review.rating ? 'text-yellow-400 fill-current' : 'text-gray-300 dark:text-gray-600'"
></span>
</template>
</div>
</div>
{# Title #}
<template x-if="review.title">
<h4 class="font-medium text-gray-900 dark:text-white mb-2" x-text="review.title"></h4>
</template>
{# Content #}
<p class="text-gray-600 dark:text-gray-400" x-text="review.content || review.body || review.text"></p>
{# Review Images #}
<template x-if="review.images?.length">
<div class="flex gap-2 mt-3">
<template x-for="image in review.images.slice(0, 4)" :key="image.id || image">
<img
:src="typeof image === 'string' ? image : image.url"
:alt="'Review image'"
class="w-16 h-16 object-cover rounded-lg cursor-pointer hover:opacity-80"
/>
</template>
</div>
</template>
{# Helpful Actions #}
<div class="flex items-center gap-4 mt-4">
<button type="button" class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
<span x-html="$icon('thumb-up', 'w-4 h-4')"></span>
<span>Helpful (<span x-text="review.helpful_count || 0"></span>)</span>
</button>
<button type="button" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
Report
</button>
</div>
</div>
{% endmacro %}
{#
Tab: Shipping & Returns
=======================
Shipping and returns policy panel.
#}
{% macro tab_shipping(tab_var='activeProductTab') %}
<div x-show="{{ tab_var }} === 'shipping'" x-transition>
<div class="space-y-6">
{# Shipping Info #}
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<span x-html="$icon('truck', 'w-5 h-5 text-purple-600')"></span>
Shipping Information
</h3>
<div class="prose prose-gray dark:prose-invert max-w-none">
<ul>
<li>Free standard shipping on orders over $50</li>
<li>Standard shipping (3-5 business days): $4.99</li>
<li>Express shipping (1-2 business days): $9.99</li>
<li>Same-day delivery available in select areas</li>
</ul>
</div>
</div>
{# Returns Info #}
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<span x-html="$icon('refresh', 'w-5 h-5 text-purple-600')"></span>
Returns & Exchanges
</h3>
<div class="prose prose-gray dark:prose-invert max-w-none">
<ul>
<li>30-day return policy for unused items</li>
<li>Free returns on all orders</li>
<li>Items must be in original packaging</li>
<li>Refunds processed within 5-7 business days</li>
</ul>
</div>
</div>
</div>
</div>
{% endmacro %}
{#
Tab: Warranty
=============
Product warranty information panel.
#}
{% macro tab_warranty(product_var='product', tab_var='activeProductTab') %}
<div x-show="{{ tab_var }} === 'warranty'" x-transition>
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<span x-html="$icon('shield-check', 'w-5 h-5 text-purple-600')"></span>
Warranty Information
</h3>
<template x-if="{{ product_var }}.warranty">
<div
class="prose prose-gray dark:prose-invert max-w-none"
x-html="{{ product_var }}.warranty"
></div>
</template>
<template x-if="!{{ product_var }}.warranty">
<div class="prose prose-gray dark:prose-invert max-w-none">
<p>This product comes with our standard warranty:</p>
<ul>
<li>1-year manufacturer warranty</li>
<li>Coverage against manufacturing defects</li>
<li>Free repairs or replacement</li>
<li>Extended warranty options available at checkout</li>
</ul>
</div>
</template>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,418 @@
{#
Variant Selector Components
===========================
Product variant selection (size, color, etc.) for product detail pages.
Usage:
{% from 'shared/macros/shop/variant-selector.html' import variant_selector, size_selector, color_swatches %}
#}
{#
Variant Selector
================
Generic variant selector that adapts to variant type.
Parameters:
- variants_var: Alpine.js expression for variants array (default: 'product.variants')
- selected_var: Alpine.js variable for selected variant (default: 'selectedVariant')
- type: 'buttons' | 'dropdown' | 'swatches' (default: 'buttons')
- label: Label text (default: 'Select Option')
- show_stock: Show stock status per variant (default: true)
- on_change: Custom change handler (default: none)
Expected variant object:
{
id: 1,
name: 'Large',
value: 'L',
stock: 10,
price_modifier: 0,
color_hex: '#FF0000', // For swatches
image_url: '...' // For swatches with preview
}
Usage:
{{ variant_selector(variants_var='product.sizes', label='Size') }}
#}
{% macro variant_selector(
variants_var='product.variants',
selected_var='selectedVariant',
type='buttons',
label='Select Option',
show_stock=true,
on_change=none
) %}
<div class="space-y-2">
{# Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ label }}
</label>
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}?.value"
></span>
</div>
{% if type == 'buttons' %}
{{ _variant_buttons(variants_var, selected_var, show_stock, on_change) }}
{% elif type == 'dropdown' %}
{{ _variant_dropdown(variants_var, selected_var, show_stock, on_change) }}
{% elif type == 'swatches' %}
{{ _variant_swatches(variants_var, selected_var, show_stock, on_change) }}
{% endif %}
</div>
{% endmacro %}
{#
Internal: Variant Buttons
#}
{% macro _variant_buttons(variants_var, selected_var, show_stock, on_change) %}
<div class="flex flex-wrap gap-2">
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<button
type="button"
@click="{{ selected_var }} = variant{{ '; ' ~ on_change if on_change else '' }}"
:disabled="variant.stock === 0"
class="px-4 py-2 text-sm font-medium rounded-lg border-2 transition-all"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': {{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': ({{ selected_var }}?.id !== variant.id && {{ selected_var }}?.value !== variant.value) && variant.stock > 0,
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed line-through': variant.stock === 0
}"
>
<span x-text="variant.name || variant.value"></span>
{% if show_stock %}
<span
x-show="variant.stock > 0 && variant.stock <= 5"
class="ml-1 text-xs text-orange-600 dark:text-orange-400"
x-text="'(' + variant.stock + ' left)'"
></span>
{% endif %}
</button>
</template>
</div>
{% endmacro %}
{#
Internal: Variant Dropdown
#}
{% macro _variant_dropdown(variants_var, selected_var, show_stock, on_change) %}
<select
x-model="{{ selected_var }}"
@change="{{ on_change if on_change else '' }}"
class="block w-full px-4 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="" disabled>Choose an option</option>
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<option
:value="JSON.stringify(variant)"
:disabled="variant.stock === 0"
x-text="(variant.name || variant.value) + (variant.stock === 0 ? ' (Out of stock)' : {{ '(variant.stock <= 5 ? \' (Only \' + variant.stock + \' left)\' : \'\')' if show_stock else '\'\'' }})"
></option>
</template>
</select>
{% endmacro %}
{#
Internal: Variant Swatches (for colors)
#}
{% macro _variant_swatches(variants_var, selected_var, show_stock, on_change) %}
<div class="flex flex-wrap gap-3">
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<button
type="button"
@click="{{ selected_var }} = variant{{ '; ' ~ on_change if on_change else '' }}"
:disabled="variant.stock === 0"
:title="(variant.name || variant.value) + (variant.stock === 0 ? ' - Out of stock' : '')"
class="relative w-10 h-10 rounded-full border-2 transition-all"
:class="{
'ring-2 ring-offset-2 ring-purple-500 dark:ring-offset-gray-800': {{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value,
'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500': ({{ selected_var }}?.id !== variant.id && {{ selected_var }}?.value !== variant.value) && variant.stock > 0,
'opacity-40 cursor-not-allowed': variant.stock === 0
}"
:style="'background-color: ' + (variant.color_hex || variant.color || '#ccc')"
>
{# Out of Stock Slash #}
<span
x-show="variant.stock === 0"
class="absolute inset-0 flex items-center justify-center"
>
<span class="w-full h-0.5 bg-gray-600 dark:bg-gray-400 rotate-45 absolute"></span>
</span>
{# Check Mark for Selected #}
<span
x-show="{{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value"
class="absolute inset-0 flex items-center justify-center"
>
<span x-html="$icon('check', 'w-5 h-5')" :class="isLightColor(variant.color_hex || variant.color) ? 'text-gray-800' : 'text-white'"></span>
</span>
</button>
</template>
</div>
{% endmacro %}
{#
Size Selector
=============
Specialized selector for clothing/shoe sizes.
Parameters:
- sizes_var: Alpine.js expression for sizes array
- selected_var: Alpine.js variable for selected size
- show_guide: Show size guide link (default: true)
- guide_action: Action for size guide button (default: none)
Usage:
{{ size_selector(sizes_var='product.sizes', guide_action='showSizeGuide = true') }}
#}
{% macro size_selector(
sizes_var='product.sizes',
selected_var='selectedSize',
show_guide=true,
guide_action=none
) %}
<div class="space-y-2">
{# Label with Size Guide #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Size
</label>
<div class="flex items-center gap-3">
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}"
></span>
{% if show_guide %}
<button
type="button"
{% if guide_action %}@click="{{ guide_action }}"{% endif %}
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
>
Size Guide
</button>
{% endif %}
</div>
</div>
{# Size Buttons #}
<div class="flex flex-wrap gap-2">
<template x-for="size in {{ sizes_var }}" :key="size.id || size.value || size">
<button
type="button"
@click="{{ selected_var }} = size"
:disabled="size.stock === 0 || (typeof size === 'object' && size.available === false)"
class="min-w-[3rem] px-3 py-2 text-sm font-medium rounded-lg border-2 transition-all text-center"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': JSON.stringify({{ selected_var }}) === JSON.stringify(size) || {{ selected_var }} === size,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': JSON.stringify({{ selected_var }}) !== JSON.stringify(size) && {{ selected_var }} !== size && (size.stock !== 0 && size.available !== false),
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed': size.stock === 0 || size.available === false
}"
>
<span x-text="size.name || size.value || size"></span>
</button>
</template>
</div>
</div>
{% endmacro %}
{#
Color Swatches
==============
Specialized selector for color options with preview.
Parameters:
- colors_var: Alpine.js expression for colors array
- selected_var: Alpine.js variable for selected color
- size: 'sm' | 'md' | 'lg' (default: 'md')
- on_change: Custom change handler (triggers image change, etc.)
Expected color object:
{
id: 1,
name: 'Red',
value: 'red',
color_hex: '#FF0000',
stock: 10,
image_url: '...' // Optional: product image for this color
}
Usage:
{{ color_swatches(colors_var='product.colors', on_change='updateProductImage(selectedColor)') }}
#}
{% macro color_swatches(
colors_var='product.colors',
selected_var='selectedColor',
size='md',
on_change=none
) %}
{% set sizes = {
'sm': {'swatch': 'w-8 h-8', 'icon': 'w-4 h-4'},
'md': {'swatch': 'w-10 h-10', 'icon': 'w-5 h-5'},
'lg': {'swatch': 'w-12 h-12', 'icon': 'w-6 h-6'}
} %}
{% set s = sizes[size] %}
<div class="space-y-2">
{# Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}?.value"
></span>
</div>
{# Color Swatches #}
<div class="flex flex-wrap gap-3">
<template x-for="color in {{ colors_var }}" :key="color.id || color.value">
<button
type="button"
@click="{{ selected_var }} = color{{ '; ' ~ on_change if on_change else '' }}"
:disabled="color.stock === 0"
:title="(color.name || color.value) + (color.stock === 0 ? ' - Out of stock' : '')"
class="relative {{ s.swatch }} rounded-full border-2 transition-all shadow-sm"
:class="{
'ring-2 ring-offset-2 ring-purple-500 dark:ring-offset-gray-800 border-gray-300': {{ selected_var }}?.id === color.id || {{ selected_var }}?.value === color.value,
'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500': ({{ selected_var }}?.id !== color.id && {{ selected_var }}?.value !== color.value) && color.stock > 0,
'opacity-40 cursor-not-allowed': color.stock === 0
}"
:style="'background-color: ' + (color.color_hex || color.color || '#ccc')"
>
{# Checkered pattern for white/light colors #}
<span
x-show="isLightColor(color.color_hex || color.color)"
class="absolute inset-0.5 rounded-full border border-gray-200"
></span>
{# Out of Stock Slash #}
<span
x-show="color.stock === 0"
class="absolute inset-0 flex items-center justify-center"
>
<span class="w-full h-0.5 bg-gray-600 rotate-45 absolute"></span>
</span>
{# Check Mark for Selected #}
<span
x-show="{{ selected_var }}?.id === color.id || {{ selected_var }}?.value === color.value"
class="absolute inset-0 flex items-center justify-center"
>
<span x-html="$icon('check', '{{ s.icon }}')" :class="isLightColor(color.color_hex || color.color) ? 'text-gray-800' : 'text-white'"></span>
</span>
</button>
</template>
</div>
</div>
{% endmacro %}
{#
Multi-Option Variant Selector
=============================
Combined selector for products with multiple option types (size + color).
Parameters:
- product_var: Alpine.js expression for product
- on_change: Callback when any variant changes
Expected product structure:
{
options: [
{ name: 'Size', values: [...] },
{ name: 'Color', values: [...] }
],
variants: [
{ id: 1, options: { size: 'M', color: 'Red' }, stock: 10, price: 99.99 }
]
}
Usage:
{{ multi_variant_selector(product_var='product') }}
#}
{% macro multi_variant_selector(
product_var='product',
on_change=none
) %}
<div
x-data="{
selectedOptions: {},
get matchingVariant() {
return {{ product_var }}.variants?.find(v => {
return Object.keys(this.selectedOptions).every(
key => v.options[key] === this.selectedOptions[key]
);
}) || null;
},
selectOption(optionName, value) {
this.selectedOptions[optionName] = value;
{{ on_change if on_change else '' }}
},
isOptionAvailable(optionName, value) {
const testOptions = { ...this.selectedOptions, [optionName]: value };
return {{ product_var }}.variants?.some(v => {
return Object.keys(testOptions).every(
key => v.options[key] === testOptions[key]
) && v.stock > 0;
});
}
}"
class="space-y-4"
>
<template x-for="option in {{ product_var }}.options" :key="option.name">
<div class="space-y-2">
{# Option Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="option.name"></label>
<span
x-show="selectedOptions[option.name]"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="selectedOptions[option.name]"
></span>
</div>
{# Option Values #}
<div class="flex flex-wrap gap-2">
<template x-for="value in option.values" :key="value">
<button
type="button"
@click="selectOption(option.name, value)"
:disabled="!isOptionAvailable(option.name, value)"
class="px-4 py-2 text-sm font-medium rounded-lg border-2 transition-all"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': selectedOptions[option.name] === value,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': selectedOptions[option.name] !== value && isOptionAvailable(option.name, value),
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed line-through': !isOptionAvailable(option.name, value)
}"
x-text="value"
></button>
</template>
</div>
</div>
</template>
{# Selected Variant Info #}
<div x-show="matchingVariant" class="pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">
<span x-show="matchingVariant?.stock > 10" class="text-green-600 dark:text-green-400">In Stock</span>
<span x-show="matchingVariant?.stock > 0 && matchingVariant?.stock <= 10" class="text-orange-600 dark:text-orange-400" x-text="'Only ' + matchingVariant?.stock + ' left'"></span>
<span x-show="matchingVariant?.stock === 0" class="text-red-600 dark:text-red-400">Out of Stock</span>
</span>
<span x-show="matchingVariant?.sku" class="text-gray-500 dark:text-gray-500" x-text="'SKU: ' + matchingVariant?.sku"></span>
</div>
</div>
</div>
{% endmacro %}