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:
398
app/templates/shared/macros/shop/product-gallery.html
Normal file
398
app/templates/shared/macros/shop/product-gallery.html
Normal 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 %}
|
||||
359
app/templates/shared/macros/shop/product-info.html
Normal file
359
app/templates/shared/macros/shop/product-info.html
Normal 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">•</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 %}
|
||||
400
app/templates/shared/macros/shop/product-tabs.html
Normal file
400
app/templates/shared/macros/shop/product-tabs.html
Normal 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 %}
|
||||
418
app/templates/shared/macros/shop/variant-selector.html
Normal file
418
app/templates/shared/macros/shop/variant-selector.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user