refactor: rename shop to storefront for consistency
Rename all "shop" directories and references to "storefront" to match the API and route naming convention already in use. Renamed directories: - app/templates/shop/ → app/templates/storefront/ - static/shop/ → static/storefront/ - app/templates/shared/macros/shop/ → .../macros/storefront/ - docs/frontend/shop/ → docs/frontend/storefront/ Renamed files: - shop.css → storefront.css - shop-layout.js → storefront-layout.js Updated references in: - app/routes/storefront_pages.py (21 template references) - app/modules/cms/routes/pages/vendor.py - app/templates/storefront/base.html (static paths) - All storefront templates (extends/includes) - docs/architecture/frontend-structure.md This aligns the template/static naming with: - Route file: storefront_pages.py - API directory: app/api/v1/storefront/ - Module routes: */routes/api/storefront.py - URL paths: /storefront/* Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
209
app/templates/shared/macros/storefront/add-to-cart.html
Normal file
209
app/templates/shared/macros/storefront/add-to-cart.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{#
|
||||
Add to Cart Components
|
||||
======================
|
||||
Standardized add-to-cart functionality with quantity selector.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/add-to-cart.html' import add_to_cart_button, add_to_cart_form %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Add to Cart Button
|
||||
==================
|
||||
Simple add to cart button with loading state.
|
||||
|
||||
Parameters:
|
||||
- product_id_var: Alpine.js expression for product ID (default: 'product.id')
|
||||
- variant_id_var: Alpine.js expression for variant ID (default: 'selectedVariant?.id')
|
||||
- quantity_var: Alpine.js variable for quantity (default: 'quantity')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'addingToCart')
|
||||
- stock_var: Alpine.js expression for stock (default: 'product.stock')
|
||||
- action: Alpine.js action to execute (default: 'addToCart()')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- full_width: Make button full width (default: true)
|
||||
- show_icon: Show cart icon (default: true)
|
||||
- label: Button label (default: 'Add to Cart')
|
||||
- loading_label: Label while loading (default: 'Adding...')
|
||||
|
||||
Usage:
|
||||
{{ add_to_cart_button() }}
|
||||
{{ add_to_cart_button(size='lg', label='Buy Now') }}
|
||||
#}
|
||||
{% macro add_to_cart_button(
|
||||
product_id_var='product.id',
|
||||
variant_id_var='selectedVariant?.id',
|
||||
quantity_var='quantity',
|
||||
loading_var='addingToCart',
|
||||
stock_var='product.stock',
|
||||
action='addToCart()',
|
||||
size='md',
|
||||
full_width=true,
|
||||
show_icon=true,
|
||||
label='Add to Cart',
|
||||
loading_label='Adding...'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'px-3 py-1.5 text-sm', 'icon': 'w-4 h-4'},
|
||||
'md': {'btn': 'px-4 py-2.5 text-base', 'icon': 'w-5 h-5'},
|
||||
'lg': {'btn': 'px-6 py-3 text-lg', 'icon': 'w-6 h-6'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ action }}"
|
||||
:disabled="{{ loading_var }} || {{ stock_var }} === 0"
|
||||
class="{{ s.btn }} {{ 'w-full' if full_width else '' }} font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{# Loading Spinner #}
|
||||
<span x-show="{{ loading_var }}" x-html="$icon('spinner', '{{ s.icon }}')"></span>
|
||||
|
||||
{# Cart Icon #}
|
||||
{% if show_icon %}
|
||||
<span x-show="!{{ loading_var }}" x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
{% endif %}
|
||||
|
||||
{# Label #}
|
||||
<span x-text="{{ loading_var }} ? '{{ loading_label }}' : ({{ stock_var }} === 0 ? 'Out of Stock' : '{{ label }}')"></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Add to Cart Form
|
||||
================
|
||||
Complete add to cart section with quantity selector.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable for product (default: 'product')
|
||||
- quantity_var: Alpine.js variable for quantity (default: 'quantity')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'addingToCart')
|
||||
- action: Alpine.js action to execute (default: 'addToCart()')
|
||||
- show_stock: Show stock status (default: true)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ add_to_cart_form() }}
|
||||
#}
|
||||
{% macro add_to_cart_form(
|
||||
product_var='product',
|
||||
quantity_var='quantity',
|
||||
loading_var='addingToCart',
|
||||
action='addToCart()',
|
||||
show_stock=true,
|
||||
size='md'
|
||||
) %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="space-y-4">
|
||||
{# Stock Status #}
|
||||
{% if show_stock %}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm"
|
||||
:class="{{ product_var }}.stock > 10 ? 'text-green-600 dark:text-green-400' : {{ product_var }}.stock > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
<span x-html="$icon({{ product_var }}.stock > 0 ? 'check-circle' : 'x-circle', 'w-4 h-4')"></span>
|
||||
<span x-text="{{ product_var }}.stock > 10 ? 'In Stock' : {{ product_var }}.stock > 0 ? 'Only ' + {{ product_var }}.stock + ' left' : 'Out of Stock'"></span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quantity and Add Button #}
|
||||
<div class="flex items-center gap-4">
|
||||
{# Quantity Selector #}
|
||||
<div class="flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Quantity</label>
|
||||
{{ number_stepper(model=quantity_var, min=1, max=product_var ~ '.stock', size=size, disabled_var=loading_var) }}
|
||||
</div>
|
||||
|
||||
{# Add to Cart Button #}
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 invisible">Action</label>
|
||||
{{ add_to_cart_button(
|
||||
stock_var=product_var ~ '.stock',
|
||||
loading_var=loading_var,
|
||||
action=action,
|
||||
size=size,
|
||||
full_width=true
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Success Message #}
|
||||
<div
|
||||
x-show="addedToCart"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="flex items-center gap-2 text-green-600 dark:text-green-400 text-sm"
|
||||
>
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
<span>Added to cart!</span>
|
||||
<a href="/cart" class="underline hover:no-underline">View Cart</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Buy Now Button
|
||||
==============
|
||||
Direct checkout button (skips cart).
|
||||
|
||||
Parameters:
|
||||
- action: Alpine.js action (default: 'buyNow()')
|
||||
- loading_var: Loading state variable (default: 'buyingNow')
|
||||
- stock_var: Stock expression (default: 'product.stock')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- full_width: Full width button (default: true)
|
||||
|
||||
Usage:
|
||||
{{ buy_now_button() }}
|
||||
#}
|
||||
{% macro buy_now_button(
|
||||
action='buyNow()',
|
||||
loading_var='buyingNow',
|
||||
stock_var='product.stock',
|
||||
size='md',
|
||||
full_width=true
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'px-3 py-1.5 text-sm', 'icon': 'w-4 h-4'},
|
||||
'md': {'btn': 'px-4 py-2.5 text-base', 'icon': 'w-5 h-5'},
|
||||
'lg': {'btn': 'px-6 py-3 text-lg', 'icon': 'w-6 h-6'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ action }}"
|
||||
:disabled="{{ loading_var }} || {{ stock_var }} === 0"
|
||||
class="{{ s.btn }} {{ 'w-full' if full_width else '' }} font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 border border-purple-200 dark:border-purple-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<span x-show="{{ loading_var }}" x-html="$icon('spinner', '{{ s.icon }}')"></span>
|
||||
<span x-show="!{{ loading_var }}" x-html="$icon('lightning-bolt', '{{ s.icon }}')"></span>
|
||||
<span>Buy Now</span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Quantity Selector (Shop variant)
|
||||
================================
|
||||
Shop-specific quantity selector with stock validation.
|
||||
|
||||
Parameters:
|
||||
- model: Alpine.js model for quantity (required)
|
||||
- max_var: Alpine.js expression for max stock (default: 'product.stock')
|
||||
- disabled_var: Alpine.js variable for disabled state (default: none)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ shop_quantity_selector(model='quantity', max_var='product.stock') }}
|
||||
#}
|
||||
{% macro shop_quantity_selector(model, max_var='product.stock', disabled_var=none, size='md') %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
{{ number_stepper(model=model, min=1, max=max_var, size=size, disabled_var=disabled_var, label='Quantity') }}
|
||||
{% endmacro %}
|
||||
262
app/templates/shared/macros/storefront/breadcrumbs.html
Normal file
262
app/templates/shared/macros/storefront/breadcrumbs.html
Normal file
@@ -0,0 +1,262 @@
|
||||
{#
|
||||
Breadcrumbs Components
|
||||
======================
|
||||
Navigation breadcrumb trail for shop pages.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/breadcrumbs.html' import shop_breadcrumbs, auto_breadcrumbs %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Shop Breadcrumbs
|
||||
================
|
||||
Breadcrumb navigation trail.
|
||||
|
||||
Parameters:
|
||||
- items: List of breadcrumb items (static)
|
||||
- items_var: Alpine.js expression for items (dynamic)
|
||||
- separator: Separator icon (default: 'chevron-right')
|
||||
- show_home: Show home link (default: true)
|
||||
- home_url: URL for home (default: '/')
|
||||
- home_label: Label for home (default: 'Home')
|
||||
|
||||
Item structure:
|
||||
{
|
||||
label: 'Category Name',
|
||||
url: '/category/...', // Optional, last item typically has no URL
|
||||
icon: 'folder' // Optional icon
|
||||
}
|
||||
|
||||
Usage (static):
|
||||
{{ shop_breadcrumbs(items=[
|
||||
{'label': 'Electronics', 'url': '/category/electronics'},
|
||||
{'label': 'Headphones', 'url': '/category/headphones'},
|
||||
{'label': 'Wireless'}
|
||||
]) }}
|
||||
|
||||
Usage (dynamic):
|
||||
{{ shop_breadcrumbs(items_var='breadcrumbs') }}
|
||||
#}
|
||||
{% macro shop_breadcrumbs(
|
||||
items=none,
|
||||
items_var=none,
|
||||
separator='chevron-right',
|
||||
show_home=true,
|
||||
home_url='/',
|
||||
home_label='Home'
|
||||
) %}
|
||||
<nav aria-label="Breadcrumb" class="flex items-center text-sm">
|
||||
<ol class="flex items-center flex-wrap gap-1">
|
||||
{% if show_home %}
|
||||
<li class="flex items-center">
|
||||
<a
|
||||
href="{{ home_url }}"
|
||||
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('home', 'w-4 h-4')"></span>
|
||||
<span class="sr-only sm:not-sr-only">{{ home_label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
{# Static items #}
|
||||
{% for item in items %}
|
||||
<li class="flex items-center">
|
||||
{% if item.url and not loop.last %}
|
||||
<a
|
||||
href="{{ item.url }}"
|
||||
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{% if item.icon %}
|
||||
<span x-html="$icon('{{ item.icon }}', 'w-4 h-4')"></span>
|
||||
{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="flex items-center gap-1 text-gray-900 dark:text-white font-medium" aria-current="page">
|
||||
{% if item.icon %}
|
||||
<span x-html="$icon('{{ item.icon }}', 'w-4 h-4')"></span>
|
||||
{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if not loop.last %}
|
||||
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% elif items_var %}
|
||||
{# Dynamic items from Alpine.js #}
|
||||
<template x-for="(item, index) in {{ items_var }}" :key="index">
|
||||
<li class="flex items-center">
|
||||
<template x-if="item.url && index < {{ items_var }}.length - 1">
|
||||
<a
|
||||
:href="item.url"
|
||||
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<span x-show="item.icon" x-html="$icon(item.icon, 'w-4 h-4')"></span>
|
||||
<span x-text="item.label"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!item.url || index === {{ items_var }}.length - 1">
|
||||
<span class="flex items-center gap-1 text-gray-900 dark:text-white font-medium" aria-current="page">
|
||||
<span x-show="item.icon" x-html="$icon(item.icon, 'w-4 h-4')"></span>
|
||||
<span x-text="item.label"></span>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
<template x-for="(item, index) in {{ items_var }}.slice(0, -1)" :key="'sep-' + index">
|
||||
<li class="flex items-center text-gray-400 dark:text-gray-500 order-last" aria-hidden="true" :style="'order: ' + (index * 2 + 1)">
|
||||
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
|
||||
</li>
|
||||
</template>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Auto Breadcrumbs
|
||||
================
|
||||
Automatically generates breadcrumbs from category hierarchy.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js expression for product (optional)
|
||||
- category_var: Alpine.js expression for current category
|
||||
- show_home: Show home link (default: true)
|
||||
|
||||
Usage:
|
||||
{{ auto_breadcrumbs(category_var='currentCategory') }}
|
||||
{{ auto_breadcrumbs(product_var='product') }}
|
||||
#}
|
||||
{% macro auto_breadcrumbs(
|
||||
product_var=none,
|
||||
category_var='currentCategory',
|
||||
show_home=true
|
||||
) %}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center text-sm"
|
||||
x-data="{
|
||||
get breadcrumbs() {
|
||||
const items = [];
|
||||
{% if product_var %}
|
||||
// Build from product's category
|
||||
let cat = {{ product_var }}?.category;
|
||||
{% else %}
|
||||
let cat = {{ category_var }};
|
||||
{% endif %}
|
||||
|
||||
// Build category path (from parent to child)
|
||||
const categoryPath = [];
|
||||
while (cat) {
|
||||
categoryPath.unshift(cat);
|
||||
cat = cat.parent;
|
||||
}
|
||||
|
||||
categoryPath.forEach(c => {
|
||||
items.push({
|
||||
label: c.name,
|
||||
url: c.url || '/category/' + c.slug
|
||||
});
|
||||
});
|
||||
|
||||
{% if product_var %}
|
||||
// Add product as last item
|
||||
items.push({
|
||||
label: {{ product_var }}?.name,
|
||||
url: null
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
return items;
|
||||
}
|
||||
}"
|
||||
>
|
||||
<ol class="flex items-center flex-wrap gap-1">
|
||||
{% if show_home %}
|
||||
<li class="flex items-center">
|
||||
<a href="/" class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
|
||||
<span x-html="$icon('home', 'w-4 h-4')"></span>
|
||||
<span class="sr-only sm:not-sr-only">Home</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<template x-for="(item, index) in breadcrumbs" :key="index">
|
||||
<li class="flex items-center">
|
||||
<template x-if="item.url && index < breadcrumbs.length - 1">
|
||||
<a :href="item.url" class="text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors" x-text="item.label"></a>
|
||||
</template>
|
||||
<template x-if="!item.url || index === breadcrumbs.length - 1">
|
||||
<span class="text-gray-900 dark:text-white font-medium" aria-current="page" x-text="item.label"></span>
|
||||
</template>
|
||||
<span x-show="index < breadcrumbs.length - 1" class="mx-2 text-gray-400 dark:text-gray-500" x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Compact Breadcrumbs
|
||||
===================
|
||||
Mobile-friendly breadcrumbs showing only parent and current.
|
||||
|
||||
Parameters:
|
||||
- parent: Parent item (static)
|
||||
- parent_var: Alpine.js expression for parent
|
||||
- current: Current page label
|
||||
- current_var: Alpine.js expression for current
|
||||
|
||||
Usage:
|
||||
{{ compact_breadcrumbs(parent={'label': 'Electronics', 'url': '/electronics'}, current='Headphones') }}
|
||||
#}
|
||||
{% macro compact_breadcrumbs(
|
||||
parent=none,
|
||||
parent_var=none,
|
||||
current=none,
|
||||
current_var=none
|
||||
) %}
|
||||
<nav aria-label="Breadcrumb" class="flex items-center text-sm">
|
||||
{% if parent %}
|
||||
<a
|
||||
href="{{ parent.url }}"
|
||||
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
|
||||
<span>{{ parent.label }}</span>
|
||||
</a>
|
||||
{% elif parent_var %}
|
||||
<a
|
||||
:href="{{ parent_var }}?.url || '/'"
|
||||
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
|
||||
<span x-text="{{ parent_var }}?.label || 'Back'"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="mx-2 text-gray-400 dark:text-gray-500">/</span>
|
||||
|
||||
{% if current %}
|
||||
<span class="text-gray-900 dark:text-white font-medium" aria-current="page">{{ current }}</span>
|
||||
{% elif current_var %}
|
||||
<span class="text-gray-900 dark:text-white font-medium" aria-current="page" x-text="{{ current_var }}"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
391
app/templates/shared/macros/storefront/category-nav.html
Normal file
391
app/templates/shared/macros/storefront/category-nav.html
Normal file
@@ -0,0 +1,391 @@
|
||||
{#
|
||||
Category Navigation Components
|
||||
==============================
|
||||
Category browsing sidebar and menu for shop navigation.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/category-nav.html' import category_nav, category_tree, category_menu %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Category Navigation
|
||||
===================
|
||||
Sidebar category navigation with nested categories.
|
||||
|
||||
Parameters:
|
||||
- categories_var: Alpine.js expression for categories array (default: 'categories')
|
||||
- current_var: Alpine.js expression for current category (default: 'currentCategory')
|
||||
- show_count: Show product counts (default: true)
|
||||
- collapsible: Make nested categories collapsible (default: true)
|
||||
- max_depth: Maximum nesting depth to show (default: 3)
|
||||
|
||||
Expected category object:
|
||||
{
|
||||
id: 1,
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
url: '/category/electronics',
|
||||
product_count: 150,
|
||||
children: [...]
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ category_nav(categories_var='categories', show_count=true) }}
|
||||
#}
|
||||
{% macro category_nav(
|
||||
categories_var='categories',
|
||||
current_var='currentCategory',
|
||||
show_count=true,
|
||||
collapsible=true,
|
||||
max_depth=3
|
||||
) %}
|
||||
<nav
|
||||
x-data="{
|
||||
expandedCategories: new Set(),
|
||||
toggleCategory(id) {
|
||||
if (this.expandedCategories.has(id)) {
|
||||
this.expandedCategories.delete(id);
|
||||
} else {
|
||||
this.expandedCategories.add(id);
|
||||
}
|
||||
},
|
||||
isExpanded(id) {
|
||||
return this.expandedCategories.has(id);
|
||||
},
|
||||
isActive(category) {
|
||||
return {{ current_var }}?.id === category.id || {{ current_var }}?.slug === category.slug;
|
||||
},
|
||||
isParentOfActive(category) {
|
||||
if (!category.children?.length) return false;
|
||||
return category.children.some(child =>
|
||||
this.isActive(child) || this.isParentOfActive(child)
|
||||
);
|
||||
}
|
||||
}"
|
||||
x-init="
|
||||
// Auto-expand parent of active category
|
||||
const expandParents = (cats) => {
|
||||
cats.forEach(cat => {
|
||||
if (isParentOfActive(cat)) {
|
||||
expandedCategories.add(cat.id);
|
||||
}
|
||||
if (cat.children?.length) expandParents(cat.children);
|
||||
});
|
||||
};
|
||||
expandParents({{ categories_var }} || []);
|
||||
"
|
||||
class="space-y-1"
|
||||
aria-label="Category navigation"
|
||||
>
|
||||
<template x-for="category in {{ categories_var }}" :key="category.id">
|
||||
{{ _category_item(current_var, show_count, collapsible, 0, max_depth) }}
|
||||
</template>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Internal: Category Item (recursive)
|
||||
#}
|
||||
{% macro _category_item(current_var, show_count, collapsible, depth, max_depth) %}
|
||||
<div class="relative">
|
||||
<div class="flex items-center">
|
||||
{# Category Link #}
|
||||
<a
|
||||
:href="category.url || '/category/' + category.slug"
|
||||
class="flex-1 flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors"
|
||||
:class="{
|
||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium': isActive(category),
|
||||
'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700': !isActive(category)
|
||||
}"
|
||||
:style="'padding-left: ' + ({{ depth }} * 12 + 12) + 'px'"
|
||||
>
|
||||
<span x-text="category.name"></span>
|
||||
{% if show_count %}
|
||||
<span
|
||||
x-show="category.product_count !== undefined"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="'(' + category.product_count + ')'"
|
||||
></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{# Expand/Collapse Button #}
|
||||
{% if collapsible %}
|
||||
<button
|
||||
x-show="category.children?.length && {{ depth }} < {{ max_depth - 1 }}"
|
||||
type="button"
|
||||
@click.prevent="toggleCategory(category.id)"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:aria-expanded="isExpanded(category.id)"
|
||||
>
|
||||
<span
|
||||
x-html="$icon('chevron-right', 'w-4 h-4 transition-transform')"
|
||||
:class="isExpanded(category.id) && 'rotate-90'"
|
||||
></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Nested Categories #}
|
||||
{% if depth < max_depth - 1 %}
|
||||
<div
|
||||
x-show="{{ 'isExpanded(category.id)' if collapsible else 'true' }} && category.children?.length"
|
||||
x-transition
|
||||
class="mt-1"
|
||||
>
|
||||
<template x-for="child in category.children" :key="child.id">
|
||||
<div x-data="{ category: child }">
|
||||
{{ _category_item(current_var, show_count, collapsible, depth + 1, max_depth) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Category Tree (Flat List)
|
||||
=========================
|
||||
Simple flat list of categories without nesting.
|
||||
|
||||
Parameters:
|
||||
- categories_var: Alpine.js expression for categories
|
||||
- current_var: Alpine.js expression for current category
|
||||
- show_count: Show product counts (default: true)
|
||||
- layout: 'vertical' | 'horizontal' (default: 'vertical')
|
||||
|
||||
Usage:
|
||||
{{ category_tree(categories_var='topCategories', layout='horizontal') }}
|
||||
#}
|
||||
{% macro category_tree(
|
||||
categories_var='categories',
|
||||
current_var='currentCategory',
|
||||
show_count=true,
|
||||
layout='vertical'
|
||||
) %}
|
||||
<nav
|
||||
class="{{ 'flex flex-wrap gap-2' if layout == 'horizontal' else 'space-y-1' }}"
|
||||
aria-label="Categories"
|
||||
>
|
||||
<template x-for="category in {{ categories_var }}" :key="category.id">
|
||||
<a
|
||||
:href="category.url || '/category/' + category.slug"
|
||||
class="{{ 'px-4 py-2 rounded-full text-sm font-medium' if layout == 'horizontal' else 'block px-3 py-2 text-sm rounded-lg' }} transition-colors"
|
||||
:class="{
|
||||
'{{ 'bg-purple-600 text-white' if layout == 'horizontal' else 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium' }}': {{ current_var }}?.id === category.id || {{ current_var }}?.slug === category.slug,
|
||||
'{{ 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' if layout == 'horizontal' else 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' }}': {{ current_var }}?.id !== category.id && {{ current_var }}?.slug !== category.slug
|
||||
}"
|
||||
>
|
||||
<span x-text="category.name"></span>
|
||||
{% if show_count %}
|
||||
<span
|
||||
x-show="category.product_count !== undefined"
|
||||
class="ml-1 text-xs {{ 'opacity-75' if layout == 'horizontal' else 'text-gray-500 dark:text-gray-400' }}"
|
||||
x-text="'(' + category.product_count + ')'"
|
||||
></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</template>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Category Menu (Dropdown/Mega Menu)
|
||||
==================================
|
||||
Horizontal category menu for header navigation.
|
||||
|
||||
Parameters:
|
||||
- categories_var: Alpine.js expression for categories
|
||||
- show_all_link: Show "All Categories" link (default: true)
|
||||
- all_link_url: URL for all categories (default: '/categories')
|
||||
|
||||
Usage:
|
||||
{{ category_menu(categories_var='mainCategories') }}
|
||||
#}
|
||||
{% macro category_menu(
|
||||
categories_var='categories',
|
||||
show_all_link=true,
|
||||
all_link_url='/categories'
|
||||
) %}
|
||||
<nav class="relative" x-data="{ openMenu: null }">
|
||||
<ul class="flex items-center gap-1">
|
||||
{% if show_all_link %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ all_link_url }}"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
|
||||
All Categories
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<template x-for="category in {{ categories_var }}" :key="category.id">
|
||||
<li class="relative" @mouseenter="openMenu = category.id" @mouseleave="openMenu = null">
|
||||
<a
|
||||
:href="category.url || '/category/' + category.slug"
|
||||
class="flex items-center gap-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span x-text="category.name"></span>
|
||||
<span x-show="category.children?.length" x-html="$icon('chevron-down', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
|
||||
{# Dropdown for subcategories #}
|
||||
<div
|
||||
x-show="openMenu === category.id && category.children?.length"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute left-0 top-full mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
<template x-for="child in category.children" :key="child.id">
|
||||
<a
|
||||
:href="child.url || '/category/' + child.slug"
|
||||
class="flex items-center justify-between px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-purple-600 dark:hover:text-purple-400"
|
||||
>
|
||||
<span x-text="child.name"></span>
|
||||
<span x-show="child.product_count" class="text-xs text-gray-500" x-text="child.product_count"></span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
{# View All Link #}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 mt-2 pt-2">
|
||||
<a
|
||||
:href="category.url || '/category/' + category.slug"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
View all <span x-text="category.name" class="ml-1"></span>
|
||||
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-auto')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mobile Category Drawer
|
||||
======================
|
||||
Full-screen category navigation for mobile devices.
|
||||
|
||||
Parameters:
|
||||
- categories_var: Alpine.js expression for categories
|
||||
- show_var: Alpine.js variable for drawer visibility (default: 'showCategoryDrawer')
|
||||
|
||||
Usage:
|
||||
{{ category_drawer(categories_var='categories', show_var='showCategoryDrawer') }}
|
||||
#}
|
||||
{% macro category_drawer(
|
||||
categories_var='categories',
|
||||
show_var='showCategoryDrawer'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 lg:hidden"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Backdrop #}
|
||||
<div
|
||||
@click="{{ show_var }} = false"
|
||||
class="absolute inset-0 bg-black/50"
|
||||
></div>
|
||||
|
||||
{# Drawer Panel #}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="-translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="-translate-x-full"
|
||||
class="absolute left-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-800 shadow-xl"
|
||||
x-data="{ selectedCategory: null, categoryStack: [] }"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
x-show="categoryStack.length > 0"
|
||||
type="button"
|
||||
@click="selectedCategory = categoryStack.pop()"
|
||||
class="p-2 -ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedCategory?.name || 'Categories'"></h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -mr-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Category List #}
|
||||
<div class="overflow-y-auto h-[calc(100%-60px)]">
|
||||
<template x-if="!selectedCategory">
|
||||
<ul class="py-2">
|
||||
<template x-for="category in {{ categories_var }}" :key="category.id">
|
||||
<li>
|
||||
<a
|
||||
:href="category.children?.length ? '#' : (category.url || '/category/' + category.slug)"
|
||||
@click.prevent="category.children?.length ? (categoryStack.push(null), selectedCategory = category) : (window.location.href = category.url || '/category/' + category.slug)"
|
||||
class="flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span x-text="category.name"></span>
|
||||
<span x-show="category.children?.length" x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedCategory">
|
||||
<ul class="py-2">
|
||||
{# View All in Category #}
|
||||
<li>
|
||||
<a
|
||||
:href="selectedCategory.url || '/category/' + selectedCategory.slug"
|
||||
class="flex items-center px-4 py-3 text-purple-600 dark:text-purple-400 font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>View all <span x-text="selectedCategory.name"></span></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="border-t border-gray-200 dark:border-gray-700 my-2"></li>
|
||||
<template x-for="child in selectedCategory.children" :key="child.id">
|
||||
<li>
|
||||
<a
|
||||
:href="child.children?.length ? '#' : (child.url || '/category/' + child.slug)"
|
||||
@click.prevent="child.children?.length ? (categoryStack.push(selectedCategory), selectedCategory = child) : (window.location.href = child.url || '/category/' + child.slug)"
|
||||
class="flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span x-text="child.name"></span>
|
||||
<span x-show="child.children?.length" x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
786
app/templates/shared/macros/storefront/filter-sidebar.html
Normal file
786
app/templates/shared/macros/storefront/filter-sidebar.html
Normal file
@@ -0,0 +1,786 @@
|
||||
{#
|
||||
Filter Sidebar Components
|
||||
=========================
|
||||
Product filtering panel for category and search pages.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/filter-sidebar.html' import filter_sidebar, filter_group, price_filter, rating_filter %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Filter Sidebar
|
||||
==============
|
||||
Complete filter sidebar with multiple filter types.
|
||||
|
||||
Parameters:
|
||||
- filters_var: Alpine.js expression for filter configuration (default: 'filters')
|
||||
- active_filters_var: Alpine.js expression for active filters (default: 'activeFilters')
|
||||
- on_change: JavaScript callback when filters change (default: 'filterProducts()')
|
||||
- show_clear: Show clear all button (default: true)
|
||||
- collapsible: Make filter groups collapsible (default: true)
|
||||
|
||||
Expected filters structure:
|
||||
{
|
||||
categories: [{ id, name, count }],
|
||||
brands: [{ id, name, count }],
|
||||
priceRange: { min: 0, max: 1000 },
|
||||
attributes: {
|
||||
color: [{ value, label, count, hex }],
|
||||
size: [{ value, label, count }]
|
||||
},
|
||||
ratings: [{ value: 5, count: 10 }, ...]
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ filter_sidebar(filters_var='filters', on_change='applyFilters()') }}
|
||||
#}
|
||||
{% macro filter_sidebar(
|
||||
filters_var='filters',
|
||||
active_filters_var='activeFilters',
|
||||
on_change='filterProducts()',
|
||||
show_clear=true,
|
||||
collapsible=true
|
||||
) %}
|
||||
<aside
|
||||
x-data="{
|
||||
expandedGroups: new Set(['categories', 'price', 'brands']),
|
||||
toggleGroup(name) {
|
||||
if (this.expandedGroups.has(name)) {
|
||||
this.expandedGroups.delete(name);
|
||||
} else {
|
||||
this.expandedGroups.add(name);
|
||||
}
|
||||
},
|
||||
isExpanded(name) {
|
||||
return this.expandedGroups.has(name);
|
||||
},
|
||||
hasActiveFilters() {
|
||||
const af = {{ active_filters_var }};
|
||||
return af.categories?.length > 0 ||
|
||||
af.brands?.length > 0 ||
|
||||
af.priceMin !== undefined ||
|
||||
af.priceMax !== undefined ||
|
||||
af.rating !== undefined ||
|
||||
Object.keys(af.attributes || {}).some(k => af.attributes[k]?.length > 0);
|
||||
},
|
||||
clearAll() {
|
||||
{{ active_filters_var }} = {
|
||||
categories: [],
|
||||
brands: [],
|
||||
priceMin: undefined,
|
||||
priceMax: undefined,
|
||||
rating: undefined,
|
||||
attributes: {},
|
||||
inStock: false
|
||||
};
|
||||
{{ on_change }};
|
||||
}
|
||||
}"
|
||||
class="space-y-6"
|
||||
>
|
||||
{# Header with Clear All #}
|
||||
{% if show_clear %}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
|
||||
<button
|
||||
type="button"
|
||||
x-show="hasActiveFilters()"
|
||||
@click="clearAll()"
|
||||
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Active Filters Tags #}
|
||||
<div x-show="hasActiveFilters()" class="flex flex-wrap gap-2">
|
||||
<template x-for="(cat, index) in {{ active_filters_var }}.categories || []" :key="'cat-' + cat">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs rounded-full">
|
||||
<span x-text="{{ filters_var }}.categories?.find(c => c.id === cat)?.name || cat"></span>
|
||||
<button type="button" @click="{{ active_filters_var }}.categories = {{ active_filters_var }}.categories.filter(c => c !== cat); {{ on_change }}" class="hover:text-purple-900 dark:hover:text-purple-100">
|
||||
<span x-html="$icon('x', 'w-3 h-3')"></span>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-for="(brand, index) in {{ active_filters_var }}.brands || []" :key="'brand-' + brand">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs rounded-full">
|
||||
<span x-text="{{ filters_var }}.brands?.find(b => b.id === brand)?.name || brand"></span>
|
||||
<button type="button" @click="{{ active_filters_var }}.brands = {{ active_filters_var }}.brands.filter(b => b !== brand); {{ on_change }}" class="hover:text-blue-900 dark:hover:text-blue-100">
|
||||
<span x-html="$icon('x', 'w-3 h-3')"></span>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="{{ active_filters_var }}.priceMin !== undefined || {{ active_filters_var }}.priceMax !== undefined">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded-full">
|
||||
<span x-text="'$' + ({{ active_filters_var }}.priceMin || 0) + ' - $' + ({{ active_filters_var }}.priceMax || {{ filters_var }}.priceRange?.max || 1000)"></span>
|
||||
<button type="button" @click="{{ active_filters_var }}.priceMin = undefined; {{ active_filters_var }}.priceMax = undefined; {{ on_change }}" class="hover:text-green-900 dark:hover:text-green-100">
|
||||
<span x-html="$icon('x', 'w-3 h-3')"></span>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Categories Filter #}
|
||||
<template x-if="{{ filters_var }}.categories?.length > 0">
|
||||
{{ _filter_group('Categories', 'categories', collapsible) }}
|
||||
</template>
|
||||
|
||||
{# Price Range Filter #}
|
||||
<template x-if="{{ filters_var }}.priceRange">
|
||||
{{ _price_range_group(filters_var, active_filters_var, on_change, collapsible) }}
|
||||
</template>
|
||||
|
||||
{# Brands Filter #}
|
||||
<template x-if="{{ filters_var }}.brands?.length > 0">
|
||||
{{ _filter_group('Brands', 'brands', collapsible) }}
|
||||
</template>
|
||||
|
||||
{# Rating Filter #}
|
||||
<template x-if="{{ filters_var }}.ratings?.length > 0">
|
||||
{{ _rating_group(filters_var, active_filters_var, on_change, collapsible) }}
|
||||
</template>
|
||||
|
||||
{# Dynamic Attribute Filters #}
|
||||
<template x-for="(options, attrName) in {{ filters_var }}.attributes || {}" :key="attrName">
|
||||
{{ _attribute_group(filters_var, active_filters_var, on_change, collapsible) }}
|
||||
</template>
|
||||
|
||||
{# Availability Filter #}
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="{{ active_filters_var }}.inStock"
|
||||
@change="{{ on_change }}"
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">In Stock Only</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Internal: Filter Group for Categories/Brands
|
||||
#}
|
||||
{% macro _filter_group(title, type, collapsible) %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{% if collapsible %}
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleGroup('{{ type }}')"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ title }}</h3>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('{{ type }}') && 'rotate-180'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">{{ title }}</h3>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
{% if collapsible %}
|
||||
x-show="isExpanded('{{ type }}')"
|
||||
x-transition
|
||||
{% endif %}
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<template x-for="item in filters.{{ type }}" :key="item.id">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
:checked="activeFilters.{{ type }}?.includes(item.id)"
|
||||
@change="
|
||||
if ($event.target.checked) {
|
||||
activeFilters.{{ type }} = [...(activeFilters.{{ type }} || []), item.id];
|
||||
} else {
|
||||
activeFilters.{{ type }} = activeFilters.{{ type }}.filter(i => i !== item.id);
|
||||
}
|
||||
filterProducts();
|
||||
"
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white" x-text="item.name"></span>
|
||||
<span x-show="item.count !== undefined" class="text-xs text-gray-400" x-text="'(' + item.count + ')'"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Internal: Price Range Group
|
||||
#}
|
||||
{% macro _price_range_group(filters_var, active_filters_var, on_change, collapsible) %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{% if collapsible %}
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleGroup('price')"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Price Range</h3>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('price') && 'rotate-180'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Price Range</h3>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
{% if collapsible %}
|
||||
x-show="isExpanded('price')"
|
||||
x-transition
|
||||
{% endif %}
|
||||
class="mt-3 space-y-4"
|
||||
x-data="{
|
||||
minPrice: {{ active_filters_var }}.priceMin ?? {{ filters_var }}.priceRange?.min ?? 0,
|
||||
maxPrice: {{ active_filters_var }}.priceMax ?? {{ filters_var }}.priceRange?.max ?? 1000,
|
||||
updatePrice() {
|
||||
{{ active_filters_var }}.priceMin = this.minPrice;
|
||||
{{ active_filters_var }}.priceMax = this.maxPrice;
|
||||
{{ on_change }};
|
||||
}
|
||||
}"
|
||||
>
|
||||
{# Dual Range Slider #}
|
||||
<div class="relative pt-1">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full">
|
||||
<div
|
||||
class="absolute h-2 bg-purple-500 rounded-full"
|
||||
:style="'left: ' + ((minPrice - ({{ filters_var }}.priceRange?.min || 0)) / (({{ filters_var }}.priceRange?.max || 1000) - ({{ filters_var }}.priceRange?.min || 0)) * 100) + '%; right: ' + (100 - (maxPrice - ({{ filters_var }}.priceRange?.min || 0)) / (({{ filters_var }}.priceRange?.max || 1000) - ({{ filters_var }}.priceRange?.min || 0)) * 100) + '%'"
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="{{ filters_var }}.priceRange?.min || 0"
|
||||
:max="{{ filters_var }}.priceRange?.max || 1000"
|
||||
x-model.number="minPrice"
|
||||
@change="if (minPrice > maxPrice) minPrice = maxPrice; updatePrice()"
|
||||
class="absolute w-full h-2 opacity-0 cursor-pointer -top-1"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
:min="{{ filters_var }}.priceRange?.min || 0"
|
||||
:max="{{ filters_var }}.priceRange?.max || 1000"
|
||||
x-model.number="maxPrice"
|
||||
@change="if (maxPrice < minPrice) maxPrice = minPrice; updatePrice()"
|
||||
class="absolute w-full h-2 opacity-0 cursor-pointer -top-1"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# Price Inputs #}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="sr-only">Min price</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="minPrice"
|
||||
:min="{{ filters_var }}.priceRange?.min || 0"
|
||||
:max="maxPrice"
|
||||
@change="updatePrice()"
|
||||
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Min"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400">-</span>
|
||||
<div class="flex-1">
|
||||
<label class="sr-only">Max price</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="maxPrice"
|
||||
:min="minPrice"
|
||||
:max="{{ filters_var }}.priceRange?.max || 1000"
|
||||
@change="updatePrice()"
|
||||
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Max"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Internal: Rating Group
|
||||
#}
|
||||
{% macro _rating_group(filters_var, active_filters_var, on_change, collapsible) %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{% if collapsible %}
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleGroup('rating')"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Rating</h3>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('rating') && 'rotate-180'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Rating</h3>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
{% if collapsible %}
|
||||
x-show="isExpanded('rating')"
|
||||
x-transition
|
||||
{% endif %}
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<template x-for="rating in {{ filters_var }}.ratings" :key="rating.value">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-filter"
|
||||
:value="rating.value"
|
||||
:checked="{{ active_filters_var }}.rating === rating.value"
|
||||
@change="{{ active_filters_var }}.rating = rating.value; {{ on_change }}"
|
||||
class="w-4 h-4 border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
x-html="$icon('star', 'w-4 h-4')"
|
||||
:class="i <= rating.value ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'"
|
||||
></span>
|
||||
</template>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">& up</span>
|
||||
</span>
|
||||
<span x-show="rating.count !== undefined" class="text-xs text-gray-400 ml-auto" x-text="'(' + rating.count + ')'"></span>
|
||||
</label>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
x-show="{{ active_filters_var }}.rating !== undefined"
|
||||
@click="{{ active_filters_var }}.rating = undefined; {{ on_change }}"
|
||||
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
Clear rating filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Internal: Dynamic Attribute Group (color, size, etc.)
|
||||
#}
|
||||
{% macro _attribute_group(filters_var, active_filters_var, on_change, collapsible) %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{% if collapsible %}
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleGroup(attrName)"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white capitalize" x-text="attrName"></h3>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded(attrName) && 'rotate-180'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white capitalize mb-3" x-text="attrName"></h3>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
{% if collapsible %}
|
||||
x-show="isExpanded(attrName)"
|
||||
x-transition
|
||||
{% endif %}
|
||||
class="mt-3"
|
||||
>
|
||||
{# Color Swatches #}
|
||||
<template x-if="attrName === 'color'">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="option in options" :key="option.value">
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
|
||||
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
|
||||
const idx = {{ active_filters_var }}.attributes[attrName].indexOf(option.value);
|
||||
if (idx === -1) {
|
||||
{{ active_filters_var }}.attributes[attrName].push(option.value);
|
||||
} else {
|
||||
{{ active_filters_var }}.attributes[attrName].splice(idx, 1);
|
||||
}
|
||||
{{ on_change }};
|
||||
"
|
||||
class="w-8 h-8 rounded-full border-2 transition-all"
|
||||
:class="({{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)) ? 'border-purple-500 ring-2 ring-purple-500/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
||||
:style="'background-color: ' + (option.hex || option.value)"
|
||||
:title="option.label"
|
||||
>
|
||||
<span class="sr-only" x-text="option.label"></span>
|
||||
<span
|
||||
x-show="{{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)"
|
||||
x-html="$icon('check', 'w-4 h-4 mx-auto')"
|
||||
:class="['#FFFFFF', '#FFF', 'white', '#FFFFF0'].includes(option.hex || option.value) ? 'text-gray-800' : 'text-white'"
|
||||
></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Size/Other Buttons #}
|
||||
<template x-if="attrName === 'size'">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="option in options" :key="option.value">
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
|
||||
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
|
||||
const idx = {{ active_filters_var }}.attributes[attrName].indexOf(option.value);
|
||||
if (idx === -1) {
|
||||
{{ active_filters_var }}.attributes[attrName].push(option.value);
|
||||
} else {
|
||||
{{ active_filters_var }}.attributes[attrName].splice(idx, 1);
|
||||
}
|
||||
{{ on_change }};
|
||||
"
|
||||
class="px-3 py-1.5 text-sm border rounded-lg transition-colors"
|
||||
:class="({{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)) ? 'bg-purple-600 text-white border-purple-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-500'"
|
||||
:disabled="option.count === 0"
|
||||
:title="option.count === 0 ? 'Not available' : ''"
|
||||
>
|
||||
<span x-text="option.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Checkbox List for other attributes #}
|
||||
<template x-if="attrName !== 'color' && attrName !== 'size'">
|
||||
<div class="space-y-2">
|
||||
<template x-for="option in options" :key="option.value">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="{{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)"
|
||||
@change="
|
||||
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
|
||||
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
|
||||
if ($event.target.checked) {
|
||||
{{ active_filters_var }}.attributes[attrName].push(option.value);
|
||||
} else {
|
||||
{{ active_filters_var }}.attributes[attrName] = {{ active_filters_var }}.attributes[attrName].filter(v => v !== option.value);
|
||||
}
|
||||
{{ on_change }};
|
||||
"
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300" x-text="option.label"></span>
|
||||
<span x-show="option.count !== undefined" class="text-xs text-gray-400" x-text="'(' + option.count + ')'"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Price Filter (Standalone)
|
||||
=========================
|
||||
Standalone price range filter component.
|
||||
|
||||
Parameters:
|
||||
- min: Minimum price (default: 0)
|
||||
- max: Maximum price (default: 1000)
|
||||
- value_min_var: Alpine.js var for min value (default: 'priceMin')
|
||||
- value_max_var: Alpine.js var for max value (default: 'priceMax')
|
||||
- on_change: Callback on change (default: 'updateFilters()')
|
||||
- currency: Currency symbol (default: '$')
|
||||
|
||||
Usage:
|
||||
{{ price_filter(min=0, max=500, on_change='filterByPrice()') }}
|
||||
#}
|
||||
{% macro price_filter(
|
||||
min=0,
|
||||
max=1000,
|
||||
value_min_var='priceMin',
|
||||
value_max_var='priceMax',
|
||||
on_change='updateFilters()',
|
||||
currency='$'
|
||||
) %}
|
||||
<div
|
||||
x-data="{
|
||||
minVal: {{ value_min_var }} ?? {{ min }},
|
||||
maxVal: {{ value_max_var }} ?? {{ max }},
|
||||
apply() {
|
||||
{{ value_min_var }} = this.minVal;
|
||||
{{ value_max_var }} = this.maxVal;
|
||||
{{ on_change }};
|
||||
}
|
||||
}"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">{{ currency }}</span>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="minVal"
|
||||
min="{{ min }}"
|
||||
:max="maxVal"
|
||||
@change="apply()"
|
||||
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Min"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400">to</span>
|
||||
<div class="flex-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">{{ currency }}</span>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="maxVal"
|
||||
:min="minVal"
|
||||
max="{{ max }}"
|
||||
@change="apply()"
|
||||
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Max"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Rating Filter (Standalone)
|
||||
==========================
|
||||
Standalone star rating filter.
|
||||
|
||||
Parameters:
|
||||
- value_var: Alpine.js var for selected rating (default: 'minRating')
|
||||
- on_change: Callback on change (default: 'updateFilters()')
|
||||
- show_count: Show product counts (default: false)
|
||||
- counts: List of counts per rating level
|
||||
|
||||
Usage:
|
||||
{{ rating_filter(value_var='minRating', on_change='filterByRating()') }}
|
||||
#}
|
||||
{% macro rating_filter(
|
||||
value_var='minRating',
|
||||
on_change='updateFilters()',
|
||||
show_count=false,
|
||||
counts=none
|
||||
) %}
|
||||
<div class="space-y-2">
|
||||
{% for i in range(5, 0, -1) %}
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
value="{{ i }}"
|
||||
:checked="{{ value_var }} === {{ i }}"
|
||||
@change="{{ value_var }} = {{ i }}; {{ on_change }}"
|
||||
class="w-4 h-4 border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<span class="flex items-center gap-0.5">
|
||||
{% for j in range(1, 6) %}
|
||||
<span x-html="$icon('star', 'w-4 h-4 {{ 'text-yellow-400' if j <= i else 'text-gray-300 dark:text-gray-600' }}')" class="{{ 'text-yellow-400' if j <= i else 'text-gray-300 dark:text-gray-600' }}"></span>
|
||||
{% endfor %}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">& up</span>
|
||||
</span>
|
||||
{% if show_count and counts %}
|
||||
<span class="text-xs text-gray-400 ml-auto">({{ counts[i-1] if counts|length >= i else '0' }})</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mobile Filter Drawer
|
||||
====================
|
||||
Full-screen filter panel for mobile devices.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable for visibility (default: 'showFilters')
|
||||
- filters_var: Alpine.js expression for filters (default: 'filters')
|
||||
- active_filters_var: Alpine.js expression for active filters (default: 'activeFilters')
|
||||
- on_apply: Callback on apply (default: 'applyFilters()')
|
||||
- result_count_var: Alpine.js var showing result count (default: 'productCount')
|
||||
|
||||
Usage:
|
||||
{{ mobile_filter_drawer(show_var='showMobileFilters') }}
|
||||
#}
|
||||
{% macro mobile_filter_drawer(
|
||||
show_var='showFilters',
|
||||
filters_var='filters',
|
||||
active_filters_var='activeFilters',
|
||||
on_apply='applyFilters()',
|
||||
result_count_var='productCount'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 lg:hidden"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Backdrop #}
|
||||
<div
|
||||
@click="{{ show_var }} = false"
|
||||
class="absolute inset-0 bg-black/50"
|
||||
></div>
|
||||
|
||||
{# Drawer Panel #}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
class="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-800 shadow-xl flex flex-col"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -mr-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Filter Content #}
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
{{ filter_sidebar(filters_var=filters_var, active_filters_var=active_filters_var, on_change=on_apply, show_clear=false, collapsible=true) }}
|
||||
</div>
|
||||
|
||||
{# Footer with Apply Button #}
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="w-full py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Show Results <span x-show="{{ result_count_var }}" x-text="'(' + {{ result_count_var }} + ')'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Filter Trigger Button
|
||||
====================
|
||||
Button to open mobile filter drawer.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable to toggle (default: 'showFilters')
|
||||
- active_count_var: Alpine.js var for active filter count (default: none)
|
||||
|
||||
Usage:
|
||||
{{ filter_trigger(show_var='showMobileFilters') }}
|
||||
#}
|
||||
{% macro filter_trigger(
|
||||
show_var='showFilters',
|
||||
active_count_var=none
|
||||
) %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = true"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors lg:hidden"
|
||||
>
|
||||
<span x-html="$icon('adjustments', 'w-5 h-5')"></span>
|
||||
<span>Filters</span>
|
||||
{% if active_count_var %}
|
||||
<span
|
||||
x-show="{{ active_count_var }} > 0"
|
||||
x-text="{{ active_count_var }}"
|
||||
class="w-5 h-5 flex items-center justify-center bg-purple-600 text-white text-xs rounded-full"
|
||||
></span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Sort Dropdown
|
||||
=============
|
||||
Product sorting dropdown.
|
||||
|
||||
Parameters:
|
||||
- value_var: Alpine.js var for sort value (default: 'sortBy')
|
||||
- options: Sort options list (default: common options)
|
||||
- on_change: Callback on change (default: 'sortProducts()')
|
||||
|
||||
Usage:
|
||||
{{ sort_dropdown(value_var='currentSort') }}
|
||||
#}
|
||||
{% macro sort_dropdown(
|
||||
value_var='sortBy',
|
||||
options=none,
|
||||
on_change='sortProducts()'
|
||||
) %}
|
||||
{% set default_options = [
|
||||
{'value': 'relevance', 'label': 'Relevance'},
|
||||
{'value': 'price_asc', 'label': 'Price: Low to High'},
|
||||
{'value': 'price_desc', 'label': 'Price: High to Low'},
|
||||
{'value': 'newest', 'label': 'Newest First'},
|
||||
{'value': 'rating', 'label': 'Highest Rated'},
|
||||
{'value': 'popular', 'label': 'Most Popular'}
|
||||
] %}
|
||||
{% set sort_options = options if options else default_options %}
|
||||
<div x-data="{ open: false }" class="relative" @click.away="open = false">
|
||||
<button
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span>Sort by:</span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="[{% for opt in sort_options %}{ value: '{{ opt.value }}', label: '{{ opt.label }}' }{{ ', ' if not loop.last }}{% endfor %}].find(o => o.value === {{ value_var }})?.label || 'Relevance'"></span>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400')"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
{% for opt in sort_options %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ value_var }} = '{{ opt.value }}'; {{ on_change }}; open = false"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
:class="{{ value_var }} === '{{ opt.value }}' ? 'text-purple-600 dark:text-purple-400 font-medium' : 'text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
408
app/templates/shared/macros/storefront/mini-cart.html
Normal file
408
app/templates/shared/macros/storefront/mini-cart.html
Normal file
@@ -0,0 +1,408 @@
|
||||
{#
|
||||
Mini Cart Components
|
||||
====================
|
||||
Cart preview dropdown and cart item components.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/mini-cart.html' import mini_cart, cart_icon_button, cart_item %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Cart Icon Button
|
||||
================
|
||||
Cart icon with item count badge for header.
|
||||
|
||||
Parameters:
|
||||
- cart_count_var: Alpine.js expression for cart item count (default: 'cart.items.length')
|
||||
- toggle_action: Alpine.js action to toggle cart dropdown (default: 'toggleCart()')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ cart_icon_button() }}
|
||||
#}
|
||||
{% macro cart_icon_button(
|
||||
cart_count_var='cart.items.length',
|
||||
toggle_action='toggleCart()',
|
||||
size='md'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'p-1.5', 'icon': 'w-5 h-5', 'badge': 'w-4 h-4 text-xs -top-1 -right-1'},
|
||||
'md': {'btn': 'p-2', 'icon': 'w-6 h-6', 'badge': 'w-5 h-5 text-xs -top-1.5 -right-1.5'},
|
||||
'lg': {'btn': 'p-2.5', 'icon': 'w-7 h-7', 'badge': 'w-6 h-6 text-sm -top-2 -right-2'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ toggle_action }}"
|
||||
class="relative {{ s.btn }} text-gray-600 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Shopping cart"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
|
||||
{# Badge #}
|
||||
<span
|
||||
x-show="{{ cart_count_var }} > 0"
|
||||
x-text="{{ cart_count_var }}"
|
||||
class="absolute {{ s.badge }} flex items-center justify-center font-bold text-white bg-purple-600 dark:bg-purple-500 rounded-full"
|
||||
></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mini Cart Dropdown
|
||||
==================
|
||||
Cart preview dropdown showing recent items.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Alpine.js variable for cart object (default: 'cart')
|
||||
- show_var: Alpine.js variable for dropdown visibility (default: 'showCart')
|
||||
- max_items: Maximum items to show (default: 3)
|
||||
- cart_url: URL to full cart page (default: '/cart')
|
||||
- checkout_url: URL to checkout (default: '/checkout')
|
||||
|
||||
Expected cart object structure:
|
||||
{
|
||||
items: [
|
||||
{id, product_id, name, image_url, price, quantity, variant_name}
|
||||
],
|
||||
subtotal: number,
|
||||
item_count: number
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ mini_cart() }}
|
||||
#}
|
||||
{% macro mini_cart(
|
||||
cart_var='cart',
|
||||
show_var='showCart',
|
||||
max_items=3,
|
||||
cart_url='/cart',
|
||||
checkout_url='/checkout'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
@click.away="{{ show_var }} = false"
|
||||
class="absolute right-0 top-full mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Shopping Cart</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="{{ cart_var }}.item_count + ' item' + ({{ cart_var }}.item_count !== 1 ? 's' : '')"></p>
|
||||
</div>
|
||||
|
||||
{# Empty State #}
|
||||
<div x-show="{{ cart_var }}.items.length === 0" class="px-4 py-8 text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600">
|
||||
<span x-html="$icon('shopping-cart', 'w-full h-full')"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Your cart is empty</p>
|
||||
<a href="/shop" class="inline-block mt-3 text-sm text-purple-600 dark:text-purple-400 hover:underline">
|
||||
Continue Shopping
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Cart Items #}
|
||||
<div x-show="{{ cart_var }}.items.length > 0" class="max-h-64 overflow-y-auto">
|
||||
<template x-for="(item, index) in {{ cart_var }}.items.slice(0, {{ max_items }})" :key="item.id">
|
||||
{{ cart_item_mini(cart_var=cart_var) }}
|
||||
</template>
|
||||
|
||||
{# More Items Notice #}
|
||||
<div
|
||||
x-show="{{ cart_var }}.items.length > {{ max_items }}"
|
||||
class="px-4 py-2 text-center text-xs text-gray-500 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span x-text="'+ ' + ({{ cart_var }}.items.length - {{ max_items }}) + ' more item' + ({{ cart_var }}.items.length - {{ max_items }} !== 1 ? 's' : '')"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div x-show="{{ cart_var }}.items.length > 0" class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Subtotal #}
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.subtotal.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="{{ cart_url }}"
|
||||
class="block w-full px-4 py-2 text-center text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg transition-colors"
|
||||
>
|
||||
View Cart
|
||||
</a>
|
||||
<a
|
||||
href="{{ checkout_url }}"
|
||||
class="block w-full px-4 py-2 text-center text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors"
|
||||
>
|
||||
Checkout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Item (Mini version)
|
||||
========================
|
||||
Compact cart item for mini cart dropdown.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Cart variable for remove action (default: 'cart')
|
||||
|
||||
Usage:
|
||||
<template x-for="item in cart.items" :key="item.id">
|
||||
{{ cart_item_mini() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro cart_item_mini(cart_var='cart') %}
|
||||
<div class="flex gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
{# Image #}
|
||||
<a :href="item.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="w-14 h-14 object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Details #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<a :href="item.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="item.name"></a>
|
||||
</h4>
|
||||
<p x-show="item.variant_name" class="text-xs text-gray-500 dark:text-gray-400" x-text="item.variant_name"></p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'Qty: ' + item.quantity"></span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" x-text="'€' + (item.price * item.quantity).toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Remove Button #}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFromCart(item.id)"
|
||||
class="flex-shrink-0 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
:aria-label="'Remove ' + item.name + ' from cart'"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Item (Full version)
|
||||
========================
|
||||
Full cart item for cart page with quantity controls.
|
||||
|
||||
Parameters:
|
||||
- item_var: Variable name for item (default: 'item')
|
||||
- index_var: Variable name for index (default: 'index')
|
||||
- show_image: Show product image (default: true)
|
||||
- editable: Allow quantity editing (default: true)
|
||||
|
||||
Usage:
|
||||
<template x-for="(item, index) in cart.items" :key="item.id">
|
||||
{{ cart_item() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro cart_item(
|
||||
item_var='item',
|
||||
index_var='index',
|
||||
show_image=true,
|
||||
editable=true
|
||||
) %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="flex gap-4 py-4 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
{# Image #}
|
||||
{% if show_image %}
|
||||
<a :href="{{ item_var }}.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="{{ item_var }}.image_url"
|
||||
:alt="{{ item_var }}.name"
|
||||
class="w-20 h-20 md:w-24 md:h-24 object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Details #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">
|
||||
<a :href="{{ item_var }}.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ item_var }}.name"></a>
|
||||
</h3>
|
||||
<p x-show="{{ item_var }}.variant_name" class="text-sm text-gray-500 dark:text-gray-400" x-text="{{ item_var }}.variant_name"></p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="'€' + {{ item_var }}.price.toFixed(2) + ' each'"></p>
|
||||
</div>
|
||||
|
||||
{# Remove Button #}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFromCart({{ item_var }}.id)"
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
:aria-label="'Remove ' + {{ item_var }}.name"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Quantity and Total #}
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
{% if editable %}
|
||||
<div>
|
||||
{{ number_stepper(
|
||||
model=item_var ~ '.quantity',
|
||||
min=1,
|
||||
max=item_var ~ '.max_quantity',
|
||||
size='sm',
|
||||
label='Quantity'
|
||||
) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="'Qty: ' + {{ item_var }}.quantity"></span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100" x-text="'€' + ({{ item_var }}.price * {{ item_var }}.quantity).toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{# Low Stock Warning #}
|
||||
<div
|
||||
x-show="{{ item_var }}.max_quantity && {{ item_var }}.max_quantity <= 5"
|
||||
class="flex items-center gap-1 mt-2 text-orange-600 dark:text-orange-400 text-xs"
|
||||
>
|
||||
<span x-html="$icon('exclamation', 'w-4 h-4')"></span>
|
||||
<span x-text="'Only ' + {{ item_var }}.max_quantity + ' left in stock'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Summary
|
||||
============
|
||||
Order summary with totals and checkout button.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Alpine.js variable for cart (default: 'cart')
|
||||
- show_promo: Show promo code input (default: true)
|
||||
- show_shipping: Show shipping estimate (default: true)
|
||||
- checkout_url: Checkout URL (default: '/checkout')
|
||||
|
||||
Expected cart structure:
|
||||
{
|
||||
subtotal: number,
|
||||
discount: number,
|
||||
shipping: number,
|
||||
tax: number,
|
||||
total: number,
|
||||
promo_code: string | null
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ cart_summary() }}
|
||||
#}
|
||||
{% macro cart_summary(
|
||||
cart_var='cart',
|
||||
show_promo=true,
|
||||
show_shipping=true,
|
||||
checkout_url='/checkout'
|
||||
) %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Order Summary</h2>
|
||||
|
||||
{# Promo Code #}
|
||||
{% if show_promo %}
|
||||
<div class="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Promo Code</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="promoCode"
|
||||
placeholder="Enter code"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="applyPromoCode()"
|
||||
:disabled="!promoCode || applyingPromo"
|
||||
class="px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="{{ cart_var }}.promo_code" class="mt-2 text-sm text-green-600 dark:text-green-400" x-text="'Code "' + {{ cart_var }}.promo_code + '" applied'"></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Totals #}
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.subtotal.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="{{ cart_var }}.discount > 0" class="flex justify-between text-green-600 dark:text-green-400">
|
||||
<span>Discount</span>
|
||||
<span x-text="'-€' + {{ cart_var }}.discount.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{% if show_shipping %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="{{ cart_var }}.shipping > 0 ? '€' + {{ cart_var }}.shipping.toFixed(2) : 'Free'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div x-show="{{ cart_var }}.tax > 0" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Tax</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.tax.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span class="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.total.toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Checkout Button #}
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href="{{ checkout_url }}"
|
||||
class="block w-full px-6 py-3 text-center text-base font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
|
||||
{# Trust Badges #}
|
||||
<div class="mt-4 flex items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
|
||||
Secure Checkout
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4')"></span>
|
||||
SSL Encrypted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
253
app/templates/shared/macros/storefront/product-card.html
Normal file
253
app/templates/shared/macros/storefront/product-card.html
Normal file
@@ -0,0 +1,253 @@
|
||||
{#
|
||||
Product Card
|
||||
============
|
||||
A versatile product card component for e-commerce listings.
|
||||
Supports multiple sizes, badges, ratings, and quick actions.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable name for the product object (default: 'product')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- show_rating: Show star rating (default: true)
|
||||
- show_quick_add: Show quick add to cart button (default: true)
|
||||
- show_wishlist: Show wishlist heart icon (default: true)
|
||||
- show_vendor: Show vendor name for marketplace (default: false)
|
||||
- add_to_cart_action: Alpine.js action for add to cart (default: 'addToCart(product)')
|
||||
- wishlist_action: Alpine.js action for wishlist toggle (default: 'toggleWishlist(product)')
|
||||
- product_url_field: Field name for product URL (default: 'url')
|
||||
- image_field: Field name for image URL (default: 'image_url')
|
||||
- title_field: Field name for product title (default: 'name')
|
||||
- price_field: Field name for price (default: 'price')
|
||||
- sale_price_field: Field name for sale price (default: 'sale_price')
|
||||
- rating_field: Field name for rating (default: 'rating')
|
||||
- review_count_field: Field name for review count (default: 'review_count')
|
||||
- stock_field: Field name for stock quantity (default: 'stock')
|
||||
- vendor_field: Field name for vendor name (default: 'vendor_name')
|
||||
|
||||
Expected product object structure:
|
||||
{
|
||||
id: number,
|
||||
name: string,
|
||||
url: string,
|
||||
image_url: string,
|
||||
price: number,
|
||||
sale_price: number | null,
|
||||
rating: number (0-5),
|
||||
review_count: number,
|
||||
stock: number,
|
||||
is_new: boolean,
|
||||
vendor_name: string (optional)
|
||||
}
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/product-card.html' import product_card %}
|
||||
|
||||
<template x-for="product in products" :key="product.id">
|
||||
{{ product_card() }}
|
||||
</template>
|
||||
|
||||
{# With custom settings #}
|
||||
{{ product_card(product_var='featuredProduct', size='lg', show_vendor=true) }}
|
||||
#}
|
||||
|
||||
{% macro product_card(
|
||||
product_var='product',
|
||||
size='md',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
show_wishlist=true,
|
||||
show_vendor=false,
|
||||
add_to_cart_action='addToCart(product)',
|
||||
wishlist_action='toggleWishlist(product)',
|
||||
product_url_field='url',
|
||||
image_field='image_url',
|
||||
title_field='name',
|
||||
price_field='price',
|
||||
sale_price_field='sale_price',
|
||||
rating_field='rating',
|
||||
review_count_field='review_count',
|
||||
stock_field='stock',
|
||||
vendor_field='vendor_name'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {
|
||||
'card': 'max-w-[200px]',
|
||||
'image': 'h-32',
|
||||
'title': 'text-sm',
|
||||
'price': 'text-sm',
|
||||
'badge': 'text-xs px-1.5 py-0.5',
|
||||
'btn': 'text-xs px-2 py-1',
|
||||
'icon': 'w-4 h-4',
|
||||
'rating': 'w-3 h-3'
|
||||
},
|
||||
'md': {
|
||||
'card': 'max-w-[280px]',
|
||||
'image': 'h-48',
|
||||
'title': 'text-base',
|
||||
'price': 'text-base',
|
||||
'badge': 'text-xs px-2 py-1',
|
||||
'btn': 'text-sm px-3 py-2',
|
||||
'icon': 'w-5 h-5',
|
||||
'rating': 'w-4 h-4'
|
||||
},
|
||||
'lg': {
|
||||
'card': 'max-w-[360px]',
|
||||
'image': 'h-64',
|
||||
'title': 'text-lg',
|
||||
'price': 'text-lg',
|
||||
'badge': 'text-sm px-2.5 py-1',
|
||||
'btn': 'text-base px-4 py-2.5',
|
||||
'icon': 'w-6 h-6',
|
||||
'rating': 'w-5 h-5'
|
||||
}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<div class="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden {{ s.card }}">
|
||||
{# Image Container #}
|
||||
<div class="relative {{ s.image }} overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<a :href="{{ product_var }}.{{ product_url_field }}">
|
||||
<img
|
||||
:src="{{ product_var }}.{{ image_field }}"
|
||||
:alt="{{ product_var }}.{{ title_field }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Badges #}
|
||||
<div class="absolute top-2 left-2 flex flex-col gap-1">
|
||||
{# Sale Badge #}
|
||||
<span
|
||||
x-show="{{ product_var }}.{{ sale_price_field }}"
|
||||
class="{{ s.badge }} font-semibold text-white bg-red-500 rounded"
|
||||
>
|
||||
<span x-text="'-' + Math.round((1 - {{ product_var }}.{{ sale_price_field }} / {{ product_var }}.{{ price_field }}) * 100) + '%'"></span>
|
||||
</span>
|
||||
{# New Badge #}
|
||||
<span
|
||||
x-show="{{ product_var }}.is_new"
|
||||
class="{{ s.badge }} font-semibold text-white bg-green-500 rounded"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Wishlist Button #}
|
||||
{% if show_wishlist %}
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="{{ wishlist_action.replace('product', product_var) }}"
|
||||
class="absolute top-2 right-2 p-1.5 rounded-full bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 transition-colors opacity-0 group-hover:opacity-100"
|
||||
:class="{{ product_var }}.in_wishlist ? 'text-red-500' : 'text-gray-400 hover:text-red-500'"
|
||||
:aria-label="{{ product_var }}.in_wishlist ? 'Remove from wishlist' : 'Add to wishlist'"
|
||||
>
|
||||
<span x-html="$icon('heart', '{{ s.icon }}')" :class="{{ product_var }}.in_wishlist && 'fill-current'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Out of Stock Overlay #}
|
||||
<div
|
||||
x-show="{{ product_var }}.{{ stock_field }} === 0"
|
||||
class="absolute inset-0 bg-black/50 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white font-semibold {{ s.badge }} bg-gray-900 rounded">Out of Stock</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="p-3">
|
||||
{# Vendor Name #}
|
||||
{% if show_vendor %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1" x-text="{{ product_var }}.{{ vendor_field }}"></p>
|
||||
{% endif %}
|
||||
|
||||
{# Title #}
|
||||
<h3 class="{{ s.title }} font-medium text-gray-900 dark:text-gray-100 mb-1 line-clamp-2">
|
||||
<a :href="{{ product_var }}.{{ product_url_field }}" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ product_var }}.{{ title_field }}"></a>
|
||||
</h3>
|
||||
|
||||
{# Rating #}
|
||||
{% if show_rating %}
|
||||
<div class="flex items-center gap-1 mb-2" x-show="{{ product_var }}.{{ rating_field }}">
|
||||
<div class="flex">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
x-html="$icon('star', '{{ s.rating }}')"
|
||||
:class="i <= Math.round({{ product_var }}.{{ rating_field }}) ? 'text-yellow-400 fill-current' : 'text-gray-300 dark:text-gray-600'"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'(' + {{ product_var }}.{{ review_count_field }} + ')'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Price #}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
class="{{ s.price }} font-bold"
|
||||
:class="{{ product_var }}.{{ sale_price_field }} ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'"
|
||||
x-text="'€' + ({{ product_var }}.{{ sale_price_field }} || {{ product_var }}.{{ price_field }}).toFixed(2)"
|
||||
></span>
|
||||
<span
|
||||
x-show="{{ product_var }}.{{ sale_price_field }}"
|
||||
class="text-sm text-gray-400 line-through"
|
||||
x-text="'€' + {{ product_var }}.{{ price_field }}.toFixed(2)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
{# Quick Add Button #}
|
||||
{% if show_quick_add %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ add_to_cart_action.replace('product', product_var) }}"
|
||||
:disabled="{{ product_var }}.{{ stock_field }} === 0"
|
||||
class="w-full {{ s.btn }} font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
<span x-text="{{ product_var }}.{{ stock_field }} === 0 ? 'Out of Stock' : 'Add to Cart'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product Badge
|
||||
=============
|
||||
Standalone badge component for product overlays.
|
||||
|
||||
Parameters:
|
||||
- type: 'sale' | 'new' | 'bestseller' | 'low_stock' | 'out_of_stock'
|
||||
- value: Optional value (e.g., '-20%' for sale, 'Only 3 left' for low_stock)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ product_badge(type='sale', value='-20%') }}
|
||||
{{ product_badge(type='new') }}
|
||||
{{ product_badge(type='low_stock', value='Only 3 left') }}
|
||||
#}
|
||||
{% macro product_badge(type, value=none, size='md') %}
|
||||
{% set badge_styles = {
|
||||
'sale': 'bg-red-500 text-white',
|
||||
'new': 'bg-green-500 text-white',
|
||||
'bestseller': 'bg-yellow-500 text-gray-900',
|
||||
'low_stock': 'bg-orange-500 text-white',
|
||||
'out_of_stock': 'bg-gray-700 text-white'
|
||||
} %}
|
||||
{% set badge_labels = {
|
||||
'sale': 'Sale',
|
||||
'new': 'New',
|
||||
'bestseller': 'Bestseller',
|
||||
'low_stock': 'Low Stock',
|
||||
'out_of_stock': 'Out of Stock'
|
||||
} %}
|
||||
{% set sizes = {
|
||||
'sm': 'text-xs px-1.5 py-0.5',
|
||||
'md': 'text-xs px-2 py-1',
|
||||
'lg': 'text-sm px-2.5 py-1'
|
||||
} %}
|
||||
<span class="{{ sizes[size] }} font-semibold {{ badge_styles[type] }} rounded">
|
||||
{{ value if value else badge_labels[type] }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
398
app/templates/shared/macros/storefront/product-gallery.html
Normal file
398
app/templates/shared/macros/storefront/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 %}
|
||||
215
app/templates/shared/macros/storefront/product-grid.html
Normal file
215
app/templates/shared/macros/storefront/product-grid.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{#
|
||||
Product Grid
|
||||
============
|
||||
Responsive grid layout for product listings.
|
||||
Includes loading skeletons and empty state.
|
||||
|
||||
Parameters:
|
||||
- products_var: Alpine.js variable containing products array (default: 'products')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'loading')
|
||||
- columns: Dict with breakpoint column counts (default: {sm: 2, md: 3, lg: 4})
|
||||
- gap: Gap size 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- card_size: Product card size 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- show_rating: Pass to product cards (default: true)
|
||||
- show_quick_add: Pass to product cards (default: true)
|
||||
- show_wishlist: Pass to product cards (default: true)
|
||||
- show_vendor: Pass to product cards (default: false)
|
||||
- empty_title: Title for empty state (default: 'No products found')
|
||||
- empty_message: Message for empty state (default: 'Try adjusting your filters')
|
||||
- empty_icon: Icon for empty state (default: 'shopping-bag')
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/product-grid.html' import product_grid, product_grid_skeleton %}
|
||||
|
||||
{{ product_grid() }}
|
||||
|
||||
{# With custom columns #}
|
||||
{{ product_grid(columns={'sm': 1, 'md': 2, 'lg': 3}) }}
|
||||
#}
|
||||
|
||||
{% macro product_grid(
|
||||
products_var='products',
|
||||
loading_var='loading',
|
||||
columns=none,
|
||||
gap='md',
|
||||
card_size='md',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
show_wishlist=true,
|
||||
show_vendor=false,
|
||||
empty_title='No products found',
|
||||
empty_message='Try adjusting your filters or search terms',
|
||||
empty_icon='shopping-bag'
|
||||
) %}
|
||||
{% from 'shared/macros/shop/product-card.html' import product_card %}
|
||||
|
||||
{% set cols = columns if columns else {'sm': 2, 'md': 3, 'lg': 4} %}
|
||||
{% set gaps = {'sm': 'gap-3', 'md': 'gap-4', 'lg': 'gap-6'} %}
|
||||
|
||||
{# Loading State #}
|
||||
<div x-show="{{ loading_var }}" class="grid grid-cols-{{ cols.sm }} md:grid-cols-{{ cols.md }} lg:grid-cols-{{ cols.lg }} {{ gaps[gap] }}">
|
||||
<template x-for="i in 8" :key="i">
|
||||
{{ product_skeleton(size=card_size) }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Empty State #}
|
||||
<div
|
||||
x-show="!{{ loading_var }} && {{ products_var }}.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 px-4"
|
||||
>
|
||||
<div class="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600">
|
||||
<span x-html="$icon('{{ empty_icon }}', 'w-full h-full')"></span>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">{{ empty_title }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm">{{ empty_message }}</p>
|
||||
</div>
|
||||
|
||||
{# Product Grid #}
|
||||
<div
|
||||
x-show="!{{ loading_var }} && {{ products_var }}.length > 0"
|
||||
class="grid grid-cols-{{ cols.sm }} md:grid-cols-{{ cols.md }} lg:grid-cols-{{ cols.lg }} {{ gaps[gap] }}"
|
||||
>
|
||||
<template x-for="product in {{ products_var }}" :key="product.id">
|
||||
{{ product_card(
|
||||
product_var='product',
|
||||
size=card_size,
|
||||
show_rating=show_rating,
|
||||
show_quick_add=show_quick_add,
|
||||
show_wishlist=show_wishlist,
|
||||
show_vendor=show_vendor
|
||||
) }}
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product Skeleton
|
||||
================
|
||||
Loading skeleton for a single product card.
|
||||
|
||||
Parameters:
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ product_skeleton() }}
|
||||
#}
|
||||
{% macro product_skeleton(size='md') %}
|
||||
{% set sizes = {
|
||||
'sm': {'card': 'max-w-[200px]', 'image': 'h-32'},
|
||||
'md': {'card': 'max-w-[280px]', 'image': 'h-48'},
|
||||
'lg': {'card': 'max-w-[360px]', 'image': 'h-64'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden {{ s.card }} animate-pulse">
|
||||
{# Image Skeleton #}
|
||||
<div class="{{ s.image }} bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
{# Content Skeleton #}
|
||||
<div class="p-3 space-y-3">
|
||||
{# Title #}
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
|
||||
{# Rating #}
|
||||
<div class="flex gap-1">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Price #}
|
||||
<div class="h-5 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||
|
||||
{# Button #}
|
||||
<div class="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product List Item
|
||||
=================
|
||||
Horizontal product card for list views.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable name (default: 'product')
|
||||
- show_rating: Show star rating (default: true)
|
||||
- show_quick_add: Show add to cart button (default: true)
|
||||
- compact: Use compact spacing (default: false)
|
||||
|
||||
Usage:
|
||||
<template x-for="product in products" :key="product.id">
|
||||
{{ product_list_item() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro product_list_item(
|
||||
product_var='product',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
compact=false
|
||||
) %}
|
||||
<div class="flex gap-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm p-{{ '3' if compact else '4' }} hover:shadow-md transition-shadow">
|
||||
{# Image #}
|
||||
<a :href="{{ product_var }}.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="{{ product_var }}.image_url"
|
||||
:alt="{{ product_var }}.name"
|
||||
class="w-{{ '20' if compact else '32' }} h-{{ '20' if compact else '32' }} object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Content #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<a :href="{{ product_var }}.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ product_var }}.name"></a>
|
||||
</h3>
|
||||
|
||||
{% if show_rating %}
|
||||
<div class="flex items-center gap-1 mt-1" x-show="{{ product_var }}.rating">
|
||||
<div class="flex">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
x-html="$icon('star', 'w-3 h-3')"
|
||||
:class="i <= Math.round({{ product_var }}.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300 dark:text-gray-600'"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'(' + {{ product_var }}.review_count + ')'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
class="font-bold"
|
||||
:class="{{ product_var }}.sale_price ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'"
|
||||
x-text="'€' + ({{ product_var }}.sale_price || {{ product_var }}.price).toFixed(2)"
|
||||
></span>
|
||||
<span
|
||||
x-show="{{ product_var }}.sale_price"
|
||||
class="text-sm text-gray-400 line-through"
|
||||
x-text="'€' + {{ product_var }}.price.toFixed(2)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
{% if show_quick_add %}
|
||||
<div class="flex-shrink-0 self-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="addToCart({{ product_var }})"
|
||||
:disabled="{{ product_var }}.stock === 0"
|
||||
class="p-2 text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:aria-label="'Add ' + {{ product_var }}.name + ' to cart'"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
359
app/templates/shared/macros/storefront/product-info.html
Normal file
359
app/templates/shared/macros/storefront/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/storefront/product-tabs.html
Normal file
400
app/templates/shared/macros/storefront/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 %}
|
||||
459
app/templates/shared/macros/storefront/reviews.html
Normal file
459
app/templates/shared/macros/storefront/reviews.html
Normal file
@@ -0,0 +1,459 @@
|
||||
{#
|
||||
Review Components
|
||||
=================
|
||||
Product review display and submission components.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/reviews.html' import review_list, review_card, review_form, review_summary %}
|
||||
#}
|
||||
|
||||
{% from 'shared/macros/shop/star-rating.html' import star_rating, rating_input, rating_summary %}
|
||||
|
||||
|
||||
{#
|
||||
Review Card
|
||||
===========
|
||||
Individual product review display.
|
||||
|
||||
Parameters:
|
||||
- review: Static review object
|
||||
- review_var: Alpine.js expression for review (dynamic)
|
||||
- show_avatar: Show reviewer avatar (default: true)
|
||||
- show_verified: Show verified purchase badge (default: true)
|
||||
- show_helpful: Show helpful buttons (default: true)
|
||||
- show_images: Show review images (default: true)
|
||||
- on_helpful: Callback for helpful button click
|
||||
|
||||
Review structure:
|
||||
{
|
||||
id: 1,
|
||||
author_name: 'John D.',
|
||||
author_avatar: null,
|
||||
rating: 5,
|
||||
title: 'Great product!',
|
||||
content: 'Really happy with this purchase...',
|
||||
verified: true,
|
||||
created_at: '2025-01-15',
|
||||
helpful_count: 42,
|
||||
images: []
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ review_card(review_var='review') }}
|
||||
#}
|
||||
{% macro review_card(
|
||||
review=none,
|
||||
review_var=none,
|
||||
show_avatar=true,
|
||||
show_verified=true,
|
||||
show_helpful=true,
|
||||
show_images=true,
|
||||
on_helpful=none
|
||||
) %}
|
||||
{% if review_var %}
|
||||
<article class="border-b border-gray-200 dark:border-gray-700 pb-6 last:border-0 last:pb-0">
|
||||
{# Header #}
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{% if show_avatar %}
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<template x-if="{{ review_var }}.author_avatar">
|
||||
<img :src="{{ review_var }}.author_avatar" :alt="{{ review_var }}.author_name" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!{{ review_var }}.author_avatar">
|
||||
<span class="text-sm font-medium text-purple-600 dark:text-purple-400" x-text="{{ review_var }}.author_name?.charAt(0)?.toUpperCase()"></span>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="{{ review_var }}.author_name"></span>
|
||||
{% if show_verified %}
|
||||
<span
|
||||
x-show="{{ review_var }}.verified"
|
||||
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 Purchase
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="{{ review_var }}.created_at"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span :class="i <= {{ review_var }}.rating ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'">
|
||||
<span x-html="$icon('star', 'w-4 h-4')"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Title #}
|
||||
<h4
|
||||
x-show="{{ review_var }}.title"
|
||||
class="font-medium text-gray-900 dark:text-white mb-2"
|
||||
x-text="{{ review_var }}.title"
|
||||
></h4>
|
||||
|
||||
{# Content #}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4" x-text="{{ review_var }}.content"></p>
|
||||
|
||||
{% if show_images %}
|
||||
{# Review Images #}
|
||||
<div
|
||||
x-show="{{ review_var }}.images?.length > 0"
|
||||
class="flex flex-wrap gap-2 mb-4"
|
||||
>
|
||||
<template x-for="(image, index) in {{ review_var }}.images || []" :key="index">
|
||||
<button
|
||||
type="button"
|
||||
class="w-16 h-16 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:border-purple-500 transition-colors"
|
||||
>
|
||||
<img :src="image" alt="Review image" class="w-full h-full object-cover">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_helpful %}
|
||||
{# Helpful Actions #}
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Was this review helpful?</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
{% if on_helpful %}@click="{{ on_helpful }}({{ review_var }}.id, true)"{% endif %}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
|
||||
>
|
||||
<span x-html="$icon('thumb-up', 'w-4 h-4')"></span>
|
||||
<span>Yes</span>
|
||||
<span x-show="{{ review_var }}.helpful_count > 0" class="text-gray-400" x-text="'(' + {{ review_var }}.helpful_count + ')'"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{% if on_helpful %}@click="{{ on_helpful }}({{ review_var }}.id, false)"{% endif %}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
>
|
||||
<span x-html="$icon('thumb-down', 'w-4 h-4')"></span>
|
||||
<span>No</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Review List
|
||||
===========
|
||||
List of reviews with optional sorting and pagination.
|
||||
|
||||
Parameters:
|
||||
- reviews_var: Alpine.js expression for reviews array
|
||||
- loading_var: Alpine.js expression for loading state
|
||||
- empty_message: Message when no reviews (default: 'No reviews yet')
|
||||
- show_sort: Show sort dropdown (default: true)
|
||||
- sort_var: Alpine.js var for sort value (default: 'reviewSort')
|
||||
- on_sort: Callback when sort changes
|
||||
|
||||
Usage:
|
||||
{{ review_list(reviews_var='reviews', loading_var='loadingReviews') }}
|
||||
#}
|
||||
{% macro review_list(
|
||||
reviews_var='reviews',
|
||||
loading_var='loading',
|
||||
empty_message='No reviews yet. Be the first to review this product!',
|
||||
show_sort=true,
|
||||
sort_var='reviewSort',
|
||||
on_sort=none
|
||||
) %}
|
||||
<div class="space-y-6">
|
||||
{% if show_sort %}
|
||||
{# Sort Controls #}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span x-text="{{ reviews_var }}?.length || 0"></span> reviews
|
||||
</span>
|
||||
<div x-data="{ open: false }" class="relative" @click.away="open = false">
|
||||
<button
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
<span>Sort by:</span>
|
||||
<span class="font-medium" x-text="{
|
||||
'newest': 'Newest',
|
||||
'oldest': 'Oldest',
|
||||
'highest': 'Highest Rated',
|
||||
'lowest': 'Lowest Rated',
|
||||
'helpful': 'Most Helpful'
|
||||
}[{{ sort_var }}] || 'Newest'"></span>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
>
|
||||
{% for opt in [
|
||||
{'value': 'newest', 'label': 'Newest'},
|
||||
{'value': 'oldest', 'label': 'Oldest'},
|
||||
{'value': 'highest', 'label': 'Highest Rated'},
|
||||
{'value': 'lowest', 'label': 'Lowest Rated'},
|
||||
{'value': 'helpful', 'label': 'Most Helpful'}
|
||||
] %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ sort_var }} = '{{ opt.value }}'; {% if on_sort %}{{ on_sort }};{% endif %} open = false"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:class="{{ sort_var }} === '{{ opt.value }}' ? 'text-purple-600 dark:text-purple-400 font-medium' : 'text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Loading State #}
|
||||
<template x-if="{{ loading_var }}">
|
||||
<div class="space-y-6">
|
||||
{% for i in range(3) %}
|
||||
<div class="animate-pulse">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
||||
<div class="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
<div class="flex gap-0.5">
|
||||
{% for j in range(5) %}
|
||||
<div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
|
||||
<div class="space-y-1">
|
||||
<div class="h-3 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="h-3 w-3/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Empty State #}
|
||||
<template x-if="!{{ loading_var }} && (!{{ reviews_var }} || {{ reviews_var }}.length === 0)">
|
||||
<div class="text-center py-8">
|
||||
<span x-html="$icon('chat-alt-2', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">{{ empty_message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Reviews #}
|
||||
<template x-if="!{{ loading_var }} && {{ reviews_var }}?.length > 0">
|
||||
<div class="space-y-6">
|
||||
<template x-for="review in {{ reviews_var }}" :key="review.id">
|
||||
{{ review_card(review_var='review') }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Review Form
|
||||
===========
|
||||
Form for submitting a product review.
|
||||
|
||||
Parameters:
|
||||
- rating_model: Alpine.js model for rating (default: 'newReview.rating')
|
||||
- title_model: Alpine.js model for title (default: 'newReview.title')
|
||||
- content_model: Alpine.js model for content (default: 'newReview.content')
|
||||
- images_model: Alpine.js model for images (default: 'newReview.images')
|
||||
- submitting_var: Alpine.js var for submitting state (default: 'submittingReview')
|
||||
- on_submit: Submit handler
|
||||
- show_images: Allow image upload (default: true)
|
||||
- require_purchase: Show purchase requirement message (default: false)
|
||||
|
||||
Usage:
|
||||
{{ review_form(on_submit='submitReview()') }}
|
||||
#}
|
||||
{% macro review_form(
|
||||
rating_model='newReview.rating',
|
||||
title_model='newReview.title',
|
||||
content_model='newReview.content',
|
||||
images_model='newReview.images',
|
||||
submitting_var='submittingReview',
|
||||
on_submit='submitReview()',
|
||||
show_images=true,
|
||||
require_purchase=false
|
||||
) %}
|
||||
<form
|
||||
@submit.prevent="{{ on_submit }}"
|
||||
class="space-y-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Write a Review</h3>
|
||||
|
||||
{% if require_purchase %}
|
||||
<div class="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">You must have purchased this product to leave a review.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Rating #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Rating <span class="text-red-500">*</span>
|
||||
</label>
|
||||
{{ rating_input(model=rating_model, size='lg') }}
|
||||
</div>
|
||||
|
||||
{# Title #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Review Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="{{ title_model }}"
|
||||
placeholder="Sum up your experience"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Review <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
x-model="{{ content_model }}"
|
||||
rows="4"
|
||||
placeholder="Share your experience with this product..."
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none resize-none"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 50 characters</p>
|
||||
</div>
|
||||
|
||||
{% if show_images %}
|
||||
{# Image Upload #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Add Photos (Optional)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="(image, index) in {{ images_model }} || []" :key="index">
|
||||
<div class="relative w-20 h-20 rounded-lg overflow-hidden group">
|
||||
<img :src="image" alt="Review image" class="w-full h-full object-cover">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ images_model }}.splice(index, 1)"
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-6 h-6 text-white')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<label
|
||||
x-show="!{{ images_model }} || {{ images_model }}.length < 5"
|
||||
class="w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center cursor-pointer hover:border-purple-500 dark:hover:border-purple-400 transition-colors"
|
||||
>
|
||||
<input type="file" accept="image/*" class="hidden" @change="
|
||||
const file = $event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (!{{ images_model }}) {{ images_model }} = [];
|
||||
{{ images_model }}.push(e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
">
|
||||
<span x-html="$icon('camera', 'w-6 h-6 text-gray-400')"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Up to 5 photos</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Submit Button #}
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="{{ submitting_var }} || !{{ rating_model }} || !{{ content_model }}"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<span x-show="!{{ submitting_var }}">Submit Review</span>
|
||||
<span x-show="{{ submitting_var }}" class="flex items-center gap-2">
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 animate-spin')"></span>
|
||||
Submitting...
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
By submitting, you agree to our review guidelines.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Review Summary Section
|
||||
======================
|
||||
Complete review summary with rating distribution and write review button.
|
||||
|
||||
Parameters:
|
||||
- rating_var: Alpine.js expression for average rating
|
||||
- count_var: Alpine.js expression for total reviews
|
||||
- distribution_var: Alpine.js expression for distribution
|
||||
- show_write_button: Show write review button (default: true)
|
||||
- on_write: Callback for write review button
|
||||
|
||||
Usage:
|
||||
{{ review_summary_section(rating_var='product.rating', count_var='product.review_count', distribution_var='product.rating_distribution') }}
|
||||
#}
|
||||
{% macro review_summary_section(
|
||||
rating_var='rating',
|
||||
count_var='reviewCount',
|
||||
distribution_var='ratingDistribution',
|
||||
show_write_button=true,
|
||||
on_write='showReviewForm = true'
|
||||
) %}
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start gap-6">
|
||||
{# Rating Summary #}
|
||||
<div class="flex-1">
|
||||
{{ rating_summary(rating_var=rating_var, count_var=count_var, distribution_var=distribution_var) }}
|
||||
</div>
|
||||
|
||||
{% if show_write_button %}
|
||||
{# Write Review CTA #}
|
||||
<div class="lg:border-l lg:border-gray-200 dark:lg:border-gray-700 lg:pl-6">
|
||||
<div class="text-center">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Share your thoughts</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Help others by sharing your experience with this product.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ on_write }}"
|
||||
class="inline-flex items-center gap-2 px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||
Write a Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
627
app/templates/shared/macros/storefront/search-bar.html
Normal file
627
app/templates/shared/macros/storefront/search-bar.html
Normal file
@@ -0,0 +1,627 @@
|
||||
{#
|
||||
Search Bar Components
|
||||
=====================
|
||||
Product search with autocomplete and suggestions for shop pages.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/search-bar.html' import search_bar, search_autocomplete, mobile_search %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Search Bar
|
||||
==========
|
||||
Basic search input with icon and optional button.
|
||||
|
||||
Parameters:
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- method: Form method (default: 'get')
|
||||
- name: Input name (default: 'q')
|
||||
- value: Initial value (default: '')
|
||||
- show_button: Show search button (default: false)
|
||||
- button_label: Button text (default: 'Search')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- variant: 'default' | 'filled' | 'minimal' (default: 'default')
|
||||
|
||||
Usage:
|
||||
{{ search_bar(placeholder='Search for products...') }}
|
||||
{{ search_bar(show_button=true, size='lg') }}
|
||||
#}
|
||||
{% macro search_bar(
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
method='get',
|
||||
name='q',
|
||||
value='',
|
||||
show_button=false,
|
||||
button_label='Search',
|
||||
size='md',
|
||||
variant='default'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'input': 'py-1.5 pl-8 pr-3 text-sm', 'icon': 'w-4 h-4 left-2.5', 'button': 'px-3 py-1.5 text-sm'},
|
||||
'md': {'input': 'py-2.5 pl-10 pr-4 text-sm', 'icon': 'w-5 h-5 left-3', 'button': 'px-4 py-2.5 text-sm'},
|
||||
'lg': {'input': 'py-3 pl-12 pr-4 text-base', 'icon': 'w-6 h-6 left-3.5', 'button': 'px-5 py-3 text-base'}
|
||||
} %}
|
||||
{% set variants = {
|
||||
'default': 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
||||
'filled': 'bg-gray-100 dark:bg-gray-700 border border-transparent focus:bg-white dark:focus:bg-gray-800 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
||||
'minimal': 'bg-transparent border-b border-gray-300 dark:border-gray-600 rounded-none focus:border-purple-500 dark:focus:border-purple-400'
|
||||
} %}
|
||||
<form action="{{ action }}" method="{{ method }}" class="relative flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
||||
<span x-html="$icon('search', 'w-full h-full')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="{{ name }}"
|
||||
value="{{ value }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
class="w-full {{ sizes[size].input }} {{ variants[variant] }} {{ 'rounded-lg' if variant != 'minimal' else '' }} text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors"
|
||||
>
|
||||
</div>
|
||||
{% if show_button %}
|
||||
<button
|
||||
type="submit"
|
||||
class="{{ sizes[size].button }} bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{{ button_label }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Search Autocomplete
|
||||
===================
|
||||
Search input with dropdown suggestions and autocomplete.
|
||||
|
||||
Parameters:
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
||||
- min_chars: Minimum characters to trigger search (default: 2)
|
||||
- debounce: Debounce delay in ms (default: 300)
|
||||
- show_recent: Show recent searches (default: true)
|
||||
- show_popular: Show popular searches (default: true)
|
||||
- max_suggestions: Maximum suggestions to show (default: 5)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ search_autocomplete(search_endpoint='/api/products/search') }}
|
||||
#}
|
||||
{% macro search_autocomplete(
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
search_endpoint='/api/search/suggest',
|
||||
min_chars=2,
|
||||
debounce=300,
|
||||
show_recent=true,
|
||||
show_popular=true,
|
||||
max_suggestions=5,
|
||||
size='md'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'input': 'py-1.5 pl-8 pr-8 text-sm', 'icon': 'w-4 h-4', 'dropdown': 'mt-1'},
|
||||
'md': {'input': 'py-2.5 pl-10 pr-10 text-sm', 'icon': 'w-5 h-5', 'dropdown': 'mt-2'},
|
||||
'lg': {'input': 'py-3 pl-12 pr-12 text-base', 'icon': 'w-6 h-6', 'dropdown': 'mt-2'}
|
||||
} %}
|
||||
<div
|
||||
x-data="{
|
||||
query: '',
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
suggestions: [],
|
||||
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
||||
popularSearches: ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books'],
|
||||
selectedIndex: -1,
|
||||
|
||||
async search() {
|
||||
if (this.query.length < {{ min_chars }}) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
||||
const data = await response.json();
|
||||
this.suggestions = (data.suggestions || data.results || data).slice(0, {{ max_suggestions }});
|
||||
} catch (e) {
|
||||
this.suggestions = [];
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
selectSuggestion(suggestion) {
|
||||
this.query = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
||||
this.saveRecent(this.query);
|
||||
this.$refs.form.submit();
|
||||
},
|
||||
|
||||
saveRecent(term) {
|
||||
let recent = this.recentSearches.filter(s => s !== term);
|
||||
recent.unshift(term);
|
||||
recent = recent.slice(0, 5);
|
||||
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
||||
this.recentSearches = recent;
|
||||
},
|
||||
|
||||
clearRecent() {
|
||||
localStorage.removeItem('recentSearches');
|
||||
this.recentSearches = [];
|
||||
},
|
||||
|
||||
handleKeydown(e) {
|
||||
const items = this.getItems();
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||
} else if (e.key === 'Enter' && this.selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
this.selectSuggestion(items[this.selectedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
|
||||
getItems() {
|
||||
if (this.query.length >= {{ min_chars }}) return this.suggestions;
|
||||
return [...this.recentSearches, ...this.popularSearches.filter(p => !this.recentSearches.includes(p))];
|
||||
}
|
||||
}"
|
||||
x-init="$watch('query', () => { selectedIndex = -1; })"
|
||||
class="relative"
|
||||
@click.away="isOpen = false"
|
||||
>
|
||||
<form x-ref="form" action="{{ action }}" method="get" @submit="saveRecent(query)">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
||||
<span x-html="$icon('search', 'w-full h-full')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
x-model="query"
|
||||
@input.debounce.{{ debounce }}ms="search()"
|
||||
@focus="isOpen = true"
|
||||
@keydown="handleKeydown($event)"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
class="w-full {{ sizes[size].input }} bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none transition-colors"
|
||||
>
|
||||
<span
|
||||
x-show="isLoading"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-full h-full animate-spin')"></span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="query.length > 0 && !isLoading"
|
||||
@click="query = ''; suggestions = []; $refs.form.q.focus()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-full h-full')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Dropdown #}
|
||||
<div
|
||||
x-show="isOpen && (query.length >= {{ min_chars }} ? suggestions.length > 0 : (recentSearches.length > 0 || {{ 'true' if show_popular else 'false' }}))"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute left-0 right-0 {{ sizes[size].dropdown }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Search Suggestions #}
|
||||
<template x-if="query.length >= {{ min_chars }}">
|
||||
<ul class="py-2">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('search', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<template x-if="typeof suggestion === 'string'">
|
||||
<span class="text-gray-900 dark:text-white" x-text="suggestion"></span>
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'object'">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white truncate" x-text="suggestion.name"></p>
|
||||
<p x-show="suggestion.category" class="text-xs text-gray-500 dark:text-gray-400" x-text="'in ' + suggestion.category"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
||||
<img :src="suggestion.image" :alt="suggestion.name" class="w-10 h-10 object-cover rounded">
|
||||
</template>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
{# Recent & Popular Searches #}
|
||||
<template x-if="query.length < {{ min_chars }}">
|
||||
<div class="py-2">
|
||||
{% if show_recent %}
|
||||
<template x-if="recentSearches.length > 0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recent Searches</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearRecent()"
|
||||
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>Clear</button>
|
||||
</div>
|
||||
<template x-for="(term, index) in recentSearches" :key="'recent-' + index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('clock', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
{% endif %}
|
||||
|
||||
{% if show_popular %}
|
||||
<div :class="recentSearches.length > 0 && 'border-t border-gray-200 dark:border-gray-700 mt-2 pt-2'">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Popular Searches</span>
|
||||
</div>
|
||||
<template x-for="(term, index) in popularSearches" :key="'popular-' + index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
@mouseenter="selectedIndex = recentSearches.length + index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === recentSearches.length + index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('trending-up', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mobile Search
|
||||
=============
|
||||
Full-screen search overlay for mobile devices.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable for visibility (default: 'showMobileSearch')
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
||||
|
||||
Usage:
|
||||
{{ mobile_search(show_var='showSearch') }}
|
||||
#}
|
||||
{% macro mobile_search(
|
||||
show_var='showMobileSearch',
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
search_endpoint='/api/search/suggest'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
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"
|
||||
class="fixed inset-0 z-50 bg-white dark:bg-gray-900 lg:hidden"
|
||||
style="display: none;"
|
||||
x-data="{
|
||||
query: '',
|
||||
suggestions: [],
|
||||
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
||||
isLoading: false,
|
||||
|
||||
async search() {
|
||||
if (this.query.length < 2) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
||||
const data = await response.json();
|
||||
this.suggestions = (data.suggestions || data.results || data).slice(0, 8);
|
||||
} catch (e) {
|
||||
this.suggestions = [];
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
selectSuggestion(suggestion) {
|
||||
const term = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
||||
this.saveRecent(term);
|
||||
window.location.href = '{{ action }}?q=' + encodeURIComponent(term);
|
||||
},
|
||||
|
||||
saveRecent(term) {
|
||||
let recent = this.recentSearches.filter(s => s !== term);
|
||||
recent.unshift(term);
|
||||
recent = recent.slice(0, 5);
|
||||
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
||||
this.recentSearches = recent;
|
||||
},
|
||||
|
||||
clearRecent() {
|
||||
localStorage.removeItem('recentSearches');
|
||||
this.recentSearches = [];
|
||||
}
|
||||
}"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<form action="{{ action }}" method="get" class="flex-1" @submit="saveRecent(query)">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
class="w-full py-2 px-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
x-show="query.length > 0"
|
||||
@click="query = ''; suggestions = []"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{# Loading State #}
|
||||
<div x-show="isLoading" class="flex items-center justify-center py-8">
|
||||
<span x-html="$icon('refresh', 'w-6 h-6 text-gray-400 animate-spin')"></span>
|
||||
</div>
|
||||
|
||||
{# Search Results #}
|
||||
<template x-if="!isLoading && query.length >= 2 && suggestions.length > 0">
|
||||
<ul class="py-2">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
||||
<img :src="suggestion.image" :alt="suggestion.name" class="w-12 h-12 object-cover rounded">
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'string' || !suggestion.image">
|
||||
<span class="w-12 h-12 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white font-medium truncate" x-text="typeof suggestion === 'string' ? suggestion : suggestion.name"></p>
|
||||
<template x-if="typeof suggestion === 'object'">
|
||||
<p x-show="suggestion.category || suggestion.price" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="suggestion.category"></span>
|
||||
<span x-show="suggestion.category && suggestion.price"> · </span>
|
||||
<span x-show="suggestion.price" x-text="'$' + suggestion.price"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
{# No Results #}
|
||||
<template x-if="!isLoading && query.length >= 2 && suggestions.length === 0">
|
||||
<div class="text-center py-8">
|
||||
<span x-html="$icon('search', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">No results found for "<span x-text="query"></span>"</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Recent Searches #}
|
||||
<template x-if="query.length < 2 && recentSearches.length > 0">
|
||||
<div class="py-4">
|
||||
<div class="flex items-center justify-between px-4 mb-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Recent Searches</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearRecent()"
|
||||
class="text-sm text-purple-600 dark:text-purple-400"
|
||||
>Clear all</button>
|
||||
</div>
|
||||
<template x-for="(term, index) in recentSearches" :key="index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span x-html="$icon('clock', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<span class="flex-1 text-gray-900 dark:text-white" x-text="term"></span>
|
||||
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Search Trigger Button
|
||||
====================
|
||||
Button to open mobile search or focus desktop search.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable to toggle (default: 'showMobileSearch')
|
||||
- sr_label: Screen reader label (default: 'Open search')
|
||||
|
||||
Usage:
|
||||
{{ search_trigger(show_var='showSearch') }}
|
||||
#}
|
||||
{% macro search_trigger(
|
||||
show_var='showMobileSearch',
|
||||
sr_label='Open search'
|
||||
) %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = true"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:hidden"
|
||||
>
|
||||
<span x-html="$icon('search', 'w-5 h-5')"></span>
|
||||
<span class="sr-only">{{ sr_label }}</span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Instant Search Results
|
||||
=====================
|
||||
Inline search results component (for header search).
|
||||
|
||||
Parameters:
|
||||
- results_var: Alpine.js expression for search results (default: 'searchResults')
|
||||
- loading_var: Alpine.js expression for loading state (default: 'isSearching')
|
||||
- query_var: Alpine.js expression for search query (default: 'searchQuery')
|
||||
- show_var: Alpine.js expression for visibility (default: 'showResults')
|
||||
|
||||
Usage:
|
||||
{{ instant_search_results(results_var='searchResults') }}
|
||||
#}
|
||||
{% macro instant_search_results(
|
||||
results_var='searchResults',
|
||||
loading_var='isSearching',
|
||||
query_var='searchQuery',
|
||||
show_var='showResults'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Loading #}
|
||||
<div x-show="{{ loading_var }}" class="flex items-center justify-center py-6">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 text-gray-400 animate-spin')"></span>
|
||||
<span class="ml-2 text-sm text-gray-500">Searching...</span>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<template x-if="!{{ loading_var }} && {{ results_var }}.length > 0">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{# Products #}
|
||||
<div class="py-2">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Products</span>
|
||||
</div>
|
||||
<template x-for="product in {{ results_var }}.filter(r => r.type === 'product').slice(0, 4)" :key="product.id">
|
||||
<a
|
||||
:href="product.url"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<img :src="product.image" :alt="product.name" class="w-10 h-10 object-cover rounded">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-900 dark:text-white truncate" x-text="product.name"></p>
|
||||
<p class="text-sm text-purple-600 dark:text-purple-400 font-medium" x-text="'$' + product.price"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Categories #}
|
||||
<template x-if="{{ results_var }}.filter(r => r.type === 'category').length > 0">
|
||||
<div class="py-2">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</span>
|
||||
</div>
|
||||
<template x-for="category in {{ results_var }}.filter(r => r.type === 'category').slice(0, 3)" :key="category.id">
|
||||
<a
|
||||
:href="category.url"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
||||
<span x-html="$icon('folder', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white" x-text="category.name"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# View All Results #}
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50">
|
||||
<a
|
||||
:href="'/search?q=' + encodeURIComponent({{ query_var }})"
|
||||
class="flex items-center justify-center gap-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
View all results
|
||||
<span x-html="$icon('arrow-right', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# No Results #}
|
||||
<template x-if="!{{ loading_var }} && {{ results_var }}.length === 0 && {{ query_var }}.length >= 2">
|
||||
<div class="py-8 text-center">
|
||||
<span x-html="$icon('search', 'w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No results found</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
389
app/templates/shared/macros/storefront/star-rating.html
Normal file
389
app/templates/shared/macros/storefront/star-rating.html
Normal file
@@ -0,0 +1,389 @@
|
||||
{#
|
||||
Star Rating Components
|
||||
======================
|
||||
Reusable star rating display and input for product reviews.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/star-rating.html' import star_rating, rating_input, rating_summary %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Star Rating Display
|
||||
===================
|
||||
Static star rating display.
|
||||
|
||||
Parameters:
|
||||
- rating: Numeric rating value (0-5)
|
||||
- rating_var: Alpine.js expression for rating (dynamic)
|
||||
- max: Maximum stars (default: 5)
|
||||
- size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' (default: 'md')
|
||||
- show_value: Show numeric value (default: false)
|
||||
- show_count: Show review count (default: false)
|
||||
- count: Number of reviews
|
||||
- count_var: Alpine.js expression for count
|
||||
- precision: 'full' | 'half' | 'exact' (default: 'half')
|
||||
- color: Star color class (default: 'text-yellow-400')
|
||||
- empty_color: Empty star color (default: 'text-gray-300 dark:text-gray-600')
|
||||
|
||||
Usage:
|
||||
{{ star_rating(rating=4.5) }}
|
||||
{{ star_rating(rating_var='product.rating', show_count=true, count_var='product.review_count') }}
|
||||
#}
|
||||
{% macro star_rating(
|
||||
rating=none,
|
||||
rating_var=none,
|
||||
max=5,
|
||||
size='md',
|
||||
show_value=false,
|
||||
show_count=false,
|
||||
count=none,
|
||||
count_var=none,
|
||||
precision='half',
|
||||
color='text-yellow-400',
|
||||
empty_color='text-gray-300 dark:text-gray-600'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-3 h-3',
|
||||
'sm': 'w-4 h-4',
|
||||
'md': 'w-5 h-5',
|
||||
'lg': 'w-6 h-6',
|
||||
'xl': 'w-8 h-8'
|
||||
} %}
|
||||
{% set text_sizes = {
|
||||
'xs': 'text-xs',
|
||||
'sm': 'text-sm',
|
||||
'md': 'text-sm',
|
||||
'lg': 'text-base',
|
||||
'xl': 'text-lg'
|
||||
} %}
|
||||
<div class="flex items-center gap-1">
|
||||
{% if rating is not none %}
|
||||
{# Static rating #}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{% for i in range(1, max + 1) %}
|
||||
{% if precision == 'half' %}
|
||||
{% set fill = 'full' if rating >= i else ('half' if rating >= i - 0.5 else 'empty') %}
|
||||
{% elif precision == 'exact' %}
|
||||
{% set fill_percent = ((rating - (i - 1)) * 100) | int %}
|
||||
{% set fill_percent = [0, [fill_percent, 100] | min] | max %}
|
||||
{% else %}
|
||||
{% set fill = 'full' if rating >= i - 0.5 else 'empty' %}
|
||||
{% endif %}
|
||||
|
||||
{% if precision == 'exact' %}
|
||||
<span class="relative {{ sizes[size] }}">
|
||||
<span class="absolute inset-0 {{ empty_color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span class="absolute inset-0 {{ color }} overflow-hidden" style="width: {{ fill_percent }}%">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</span>
|
||||
{% elif fill == 'half' %}
|
||||
<span class="relative {{ sizes[size] }}">
|
||||
<span class="absolute inset-0 {{ empty_color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span class="absolute inset-0 {{ color }} overflow-hidden" style="width: 50%">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="{{ color if fill == 'full' else empty_color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if show_value %}
|
||||
<span class="{{ text_sizes[size] }} font-medium text-gray-900 dark:text-white ml-1">{{ rating }}</span>
|
||||
{% endif %}
|
||||
{% if show_count and count is not none %}
|
||||
<span class="{{ text_sizes[size] }} text-gray-500 dark:text-gray-400">({{ count }})</span>
|
||||
{% endif %}
|
||||
|
||||
{% elif rating_var %}
|
||||
{# Dynamic rating from Alpine.js #}
|
||||
<div class="flex items-center gap-0.5">
|
||||
<template x-for="i in {{ max }}" :key="i">
|
||||
<span class="relative {{ sizes[size] }}">
|
||||
<template x-if="{% if precision == 'exact' %}true{% else %}{{ rating_var }} >= i - 0.5 && {{ rating_var }} < i{% endif %}">
|
||||
<span class="relative {{ sizes[size] }}">
|
||||
<span class="absolute inset-0 {{ empty_color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span class="absolute inset-0 {{ color }} overflow-hidden" :style="'width: ' + {% if precision == 'exact' %}Math.min(100, Math.max(0, ({{ rating_var }} - (i - 1)) * 100)){% else %}50{% endif %} + '%'">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="{{ rating_var }} >= i">
|
||||
<span class="{{ color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="{{ rating_var }} < i - 0.5">
|
||||
<span class="{{ empty_color }}">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
{% if show_value %}
|
||||
<span class="{{ text_sizes[size] }} font-medium text-gray-900 dark:text-white ml-1" x-text="{{ rating_var }}?.toFixed(1)"></span>
|
||||
{% endif %}
|
||||
{% if show_count and count_var %}
|
||||
<span class="{{ text_sizes[size] }} text-gray-500 dark:text-gray-400" x-text="'(' + {{ count_var }} + ')'"></span>
|
||||
{% elif show_count and count is not none %}
|
||||
<span class="{{ text_sizes[size] }} text-gray-500 dark:text-gray-400">({{ count }})</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Rating Input
|
||||
============
|
||||
Interactive star rating input for submitting reviews.
|
||||
|
||||
Parameters:
|
||||
- model: Alpine.js model for rating value (default: 'rating')
|
||||
- max: Maximum stars (default: 5)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- allow_half: Allow half-star ratings (default: false)
|
||||
- allow_clear: Allow clearing rating (default: true)
|
||||
- disabled_var: Alpine.js expression for disabled state
|
||||
- on_change: Callback when rating changes
|
||||
|
||||
Usage:
|
||||
{{ rating_input(model='reviewRating') }}
|
||||
{{ rating_input(model='rating', size='lg', allow_half=true) }}
|
||||
#}
|
||||
{% macro rating_input(
|
||||
model='rating',
|
||||
max=5,
|
||||
size='md',
|
||||
allow_half=false,
|
||||
allow_clear=true,
|
||||
disabled_var=none,
|
||||
on_change=none
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': 'w-6 h-6',
|
||||
'md': 'w-8 h-8',
|
||||
'lg': 'w-10 h-10'
|
||||
} %}
|
||||
<div
|
||||
x-data="{
|
||||
hoverRating: 0,
|
||||
setRating(value) {
|
||||
if ({{ disabled_var if disabled_var else 'false' }}) return;
|
||||
if ({{ model }} === value && {{ 'true' if allow_clear else 'false' }}) {
|
||||
{{ model }} = 0;
|
||||
} else {
|
||||
{{ model }} = value;
|
||||
}
|
||||
{% if on_change %}{{ on_change }};{% endif %}
|
||||
},
|
||||
getStarValue(index, isHalf) {
|
||||
return isHalf ? index - 0.5 : index;
|
||||
}
|
||||
}"
|
||||
class="flex items-center gap-1"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': {{ disabled_var if disabled_var else 'false' }} }"
|
||||
>
|
||||
{% for i in range(1, max + 1) %}
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating({{ i }})"
|
||||
@mouseenter="hoverRating = {{ i }}"
|
||||
@mouseleave="hoverRating = 0"
|
||||
:disabled="{{ disabled_var if disabled_var else 'false' }}"
|
||||
class="relative {{ sizes[size] }} focus:outline-none transition-transform hover:scale-110 disabled:hover:scale-100"
|
||||
:class="{ 'cursor-pointer': !{{ disabled_var if disabled_var else 'false' }} }"
|
||||
>
|
||||
{% if allow_half %}
|
||||
{# Half-star support #}
|
||||
<span
|
||||
class="absolute inset-0 text-gray-300 dark:text-gray-600"
|
||||
x-show="(hoverRating || {{ model }}) < {{ i - 0.5 }}"
|
||||
>
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0"
|
||||
x-show="(hoverRating || {{ model }}) >= {{ i - 0.5 }} && (hoverRating || {{ model }}) < {{ i }}"
|
||||
>
|
||||
<span class="absolute inset-0 text-gray-300 dark:text-gray-600">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span class="absolute inset-0 text-yellow-400 overflow-hidden" style="width: 50%">
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0 text-yellow-400"
|
||||
x-show="(hoverRating || {{ model }}) >= {{ i }}"
|
||||
>
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
{# Half-star click area #}
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 w-1/2"
|
||||
@click.stop="setRating({{ i - 0.5 }})"
|
||||
></span>
|
||||
<span
|
||||
class="absolute inset-y-0 right-0 w-1/2"
|
||||
@click.stop="setRating({{ i }})"
|
||||
></span>
|
||||
{% else %}
|
||||
{# Full star only #}
|
||||
<span
|
||||
class="text-yellow-400"
|
||||
x-show="(hoverRating || {{ model }}) >= {{ i }}"
|
||||
>
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
<span
|
||||
class="text-gray-300 dark:text-gray-600"
|
||||
x-show="(hoverRating || {{ model }}) < {{ i }}"
|
||||
>
|
||||
<span x-html="$icon('star', '{{ sizes[size] }}')"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
{# Rating label #}
|
||||
<span
|
||||
x-show="{{ model }} > 0"
|
||||
class="ml-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
x-text="[
|
||||
'',
|
||||
'Poor',
|
||||
'Fair',
|
||||
'Good',
|
||||
'Very Good',
|
||||
'Excellent'
|
||||
][Math.ceil({{ model }})] || ''"
|
||||
></span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Rating Summary
|
||||
==============
|
||||
Rating distribution summary (typically shown on product pages).
|
||||
|
||||
Parameters:
|
||||
- rating_var: Alpine.js expression for average rating
|
||||
- count_var: Alpine.js expression for total reviews
|
||||
- distribution_var: Alpine.js expression for distribution object {5: count, 4: count, ...}
|
||||
- show_bars: Show distribution bars (default: true)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ rating_summary(rating_var='product.rating', count_var='product.review_count', distribution_var='product.rating_distribution') }}
|
||||
#}
|
||||
{% macro rating_summary(
|
||||
rating_var='rating',
|
||||
count_var='reviewCount',
|
||||
distribution_var='ratingDistribution',
|
||||
show_bars=true,
|
||||
size='md'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'star': 'w-4 h-4', 'text': 'text-3xl', 'bar_h': 'h-1.5'},
|
||||
'md': {'star': 'w-5 h-5', 'text': 'text-4xl', 'bar_h': 'h-2'},
|
||||
'lg': {'star': 'w-6 h-6', 'text': 'text-5xl', 'bar_h': 'h-2.5'}
|
||||
} %}
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
{# Average Rating #}
|
||||
<div class="text-center md:text-left">
|
||||
<div class="{{ sizes[size].text }} font-bold text-gray-900 dark:text-white" x-text="{{ rating_var }}?.toFixed(1)"></div>
|
||||
<div class="flex items-center justify-center md:justify-start gap-0.5 mt-2">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
:class="i <= Math.round({{ rating_var }}) ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'"
|
||||
>
|
||||
<span x-html="$icon('star', '{{ sizes[size].star }}')"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Based on <span x-text="{{ count_var }}"></span> reviews
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_bars %}
|
||||
{# Rating Distribution #}
|
||||
<div class="flex-1 space-y-2">
|
||||
{% for i in range(5, 0, -1) %}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 text-sm text-gray-600 dark:text-gray-400">{{ i }} <span x-html="$icon('star', 'w-3 h-3 inline')"></span></span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full {{ sizes[size].bar_h }} overflow-hidden">
|
||||
<div
|
||||
class="bg-yellow-400 {{ sizes[size].bar_h }} rounded-full transition-all duration-300"
|
||||
:style="'width: ' + ({{ count_var }} > 0 ? ({{ distribution_var }}[{{ i }}] || 0) / {{ count_var }} * 100 : 0) + '%'"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-10 text-sm text-gray-600 dark:text-gray-400 text-right" x-text="({{ distribution_var }}[{{ i }}] || 0)"></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Compact Rating
|
||||
==============
|
||||
Inline compact rating for lists and cards.
|
||||
|
||||
Parameters:
|
||||
- rating: Static rating value
|
||||
- rating_var: Alpine.js expression for rating
|
||||
- count: Review count
|
||||
- count_var: Alpine.js expression for count
|
||||
- size: 'xs' | 'sm' | 'md' (default: 'sm')
|
||||
|
||||
Usage:
|
||||
{{ compact_rating(rating=4.5, count=127) }}
|
||||
{{ compact_rating(rating_var='item.rating', count_var='item.reviews') }}
|
||||
#}
|
||||
{% macro compact_rating(
|
||||
rating=none,
|
||||
rating_var=none,
|
||||
count=none,
|
||||
count_var=none,
|
||||
size='sm'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'xs': {'star': 'w-3 h-3', 'text': 'text-xs'},
|
||||
'sm': {'star': 'w-4 h-4', 'text': 'text-sm'},
|
||||
'md': {'star': 'w-5 h-5', 'text': 'text-base'}
|
||||
} %}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-yellow-400">
|
||||
<span x-html="$icon('star', '{{ sizes[size].star }}')"></span>
|
||||
</span>
|
||||
{% if rating is not none %}
|
||||
<span class="{{ sizes[size].text }} font-medium text-gray-900 dark:text-white">{{ rating }}</span>
|
||||
{% if count is not none %}
|
||||
<span class="{{ sizes[size].text }} text-gray-500 dark:text-gray-400">({{ count }})</span>
|
||||
{% endif %}
|
||||
{% elif rating_var %}
|
||||
<span class="{{ sizes[size].text }} font-medium text-gray-900 dark:text-white" x-text="{{ rating_var }}?.toFixed(1)"></span>
|
||||
{% if count_var %}
|
||||
<span class="{{ sizes[size].text }} text-gray-500 dark:text-gray-400" x-text="'(' + {{ count_var }} + ')'"></span>
|
||||
{% elif count is not none %}
|
||||
<span class="{{ sizes[size].text }} text-gray-500 dark:text-gray-400">({{ count }})</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
436
app/templates/shared/macros/storefront/trust-badges.html
Normal file
436
app/templates/shared/macros/storefront/trust-badges.html
Normal file
@@ -0,0 +1,436 @@
|
||||
{#
|
||||
Trust Badge Components
|
||||
======================
|
||||
Trust signals and security indicators for e-commerce.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/trust-badges.html' import trust_badges, trust_banner, payment_icons, guarantee_badge %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Trust Badges
|
||||
============
|
||||
Grid of trust indicators.
|
||||
|
||||
Parameters:
|
||||
- badges: List of badge types to show (default: all)
|
||||
- layout: 'grid' | 'inline' | 'vertical' (default: 'grid')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- show_text: Show badge text (default: true)
|
||||
- free_shipping_threshold: Threshold for free shipping (default: none)
|
||||
|
||||
Badge Types:
|
||||
- secure_payment
|
||||
- free_shipping
|
||||
- easy_returns
|
||||
- support_24_7
|
||||
- money_back
|
||||
- ssl_secured
|
||||
- fast_delivery
|
||||
- quality_guarantee
|
||||
|
||||
Usage:
|
||||
{{ trust_badges(badges=['secure_payment', 'free_shipping', 'easy_returns']) }}
|
||||
{{ trust_badges(layout='inline', size='sm') }}
|
||||
#}
|
||||
{% macro trust_badges(
|
||||
badges=none,
|
||||
layout='grid',
|
||||
size='md',
|
||||
show_text=true,
|
||||
free_shipping_threshold=none
|
||||
) %}
|
||||
{% set all_badges = [
|
||||
{
|
||||
'id': 'secure_payment',
|
||||
'icon': 'lock-closed',
|
||||
'title': 'Secure Payment',
|
||||
'description': '256-bit SSL encryption'
|
||||
},
|
||||
{
|
||||
'id': 'free_shipping',
|
||||
'icon': 'truck',
|
||||
'title': 'Free Shipping',
|
||||
'description': 'On orders over $' ~ (free_shipping_threshold or 50)
|
||||
},
|
||||
{
|
||||
'id': 'easy_returns',
|
||||
'icon': 'refresh',
|
||||
'title': 'Easy Returns',
|
||||
'description': '30-day return policy'
|
||||
},
|
||||
{
|
||||
'id': 'support_24_7',
|
||||
'icon': 'support',
|
||||
'title': '24/7 Support',
|
||||
'description': 'Always here to help'
|
||||
},
|
||||
{
|
||||
'id': 'money_back',
|
||||
'icon': 'cash',
|
||||
'title': 'Money Back',
|
||||
'description': '100% guarantee'
|
||||
},
|
||||
{
|
||||
'id': 'ssl_secured',
|
||||
'icon': 'shield-check',
|
||||
'title': 'SSL Secured',
|
||||
'description': 'Protected checkout'
|
||||
},
|
||||
{
|
||||
'id': 'fast_delivery',
|
||||
'icon': 'lightning-bolt',
|
||||
'title': 'Fast Delivery',
|
||||
'description': '2-5 business days'
|
||||
},
|
||||
{
|
||||
'id': 'quality_guarantee',
|
||||
'icon': 'badge-check',
|
||||
'title': 'Quality Guarantee',
|
||||
'description': 'Premium products'
|
||||
}
|
||||
] %}
|
||||
{% set selected_badges = badges if badges else ['secure_payment', 'free_shipping', 'easy_returns', 'support_24_7'] %}
|
||||
{% set sizes = {
|
||||
'sm': {'icon': 'w-5 h-5', 'title': 'text-xs', 'desc': 'text-xs', 'padding': 'p-2', 'gap': 'gap-1'},
|
||||
'md': {'icon': 'w-6 h-6', 'title': 'text-sm', 'desc': 'text-xs', 'padding': 'p-3', 'gap': 'gap-2'},
|
||||
'lg': {'icon': 'w-8 h-8', 'title': 'text-base', 'desc': 'text-sm', 'padding': 'p-4', 'gap': 'gap-3'}
|
||||
} %}
|
||||
{% set layouts = {
|
||||
'grid': 'grid grid-cols-2 md:grid-cols-4 gap-4',
|
||||
'inline': 'flex flex-wrap items-center justify-center gap-6',
|
||||
'vertical': 'flex flex-col gap-3'
|
||||
} %}
|
||||
|
||||
<div class="{{ layouts[layout] }}">
|
||||
{% for badge_id in selected_badges %}
|
||||
{% for badge in all_badges %}
|
||||
{% if badge.id == badge_id %}
|
||||
<div class="flex items-center {{ sizes[size].gap }} {{ sizes[size].padding if layout == 'grid' else '' }} {{ 'bg-gray-50 dark:bg-gray-800 rounded-lg' if layout == 'grid' else '' }}">
|
||||
<div class="flex-shrink-0 text-purple-600 dark:text-purple-400">
|
||||
<span x-html="$icon('{{ badge.icon }}', '{{ sizes[size].icon }}')"></span>
|
||||
</div>
|
||||
{% if show_text %}
|
||||
<div class="{{ 'min-w-0' if layout == 'grid' else '' }}">
|
||||
<p class="font-medium text-gray-900 dark:text-white {{ sizes[size].title }} {{ 'truncate' if layout == 'grid' else '' }}">{{ badge.title }}</p>
|
||||
{% if layout != 'inline' %}
|
||||
<p class="text-gray-500 dark:text-gray-400 {{ sizes[size].desc }} {{ 'truncate' if layout == 'grid' else '' }}">{{ badge.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Trust Banner
|
||||
============
|
||||
Full-width trust banner for product pages or checkout.
|
||||
|
||||
Parameters:
|
||||
- variant: 'default' | 'compact' | 'detailed' (default: 'default')
|
||||
- show_icons: Show payment/security icons (default: true)
|
||||
|
||||
Usage:
|
||||
{{ trust_banner() }}
|
||||
{{ trust_banner(variant='compact') }}
|
||||
#}
|
||||
{% macro trust_banner(
|
||||
variant='default',
|
||||
show_icons=true
|
||||
) %}
|
||||
{% if variant == 'compact' %}
|
||||
<div class="flex flex-wrap items-center justify-center gap-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="flex items-center gap-2">
|
||||
<span x-html="$icon('truck', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||
Free shipping over $50
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||
30-day returns
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span x-html="$icon('shield-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||
Secure checkout
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% elif variant == 'detailed' %}
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-xl p-6">
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-3 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
<span x-html="$icon('truck', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Free Shipping</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">On all orders over $50. International shipping available.</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-3 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<span x-html="$icon('refresh', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Easy Returns</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">30-day hassle-free return policy. No questions asked.</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-3 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
<span x-html="$icon('shield-check', 'w-6 h-6 text-blue-600 dark:text-blue-400')"></span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Secure Payment</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Your payment info is protected with 256-bit encryption.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_icons %}
|
||||
<div class="mt-6 pt-6 border-t border-purple-200 dark:border-purple-800">
|
||||
{{ payment_icons(size='sm') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# Default variant #}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('shield-check', 'w-8 h-8 text-green-600 dark:text-green-400')"></span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">Secure Shopping Guarantee</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Shop with confidence - your data is protected</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_icons %}
|
||||
{{ payment_icons(size='sm') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Payment Icons
|
||||
=============
|
||||
Display accepted payment method icons.
|
||||
|
||||
Parameters:
|
||||
- methods: List of payment methods (default: common cards)
|
||||
- size: 'xs' | 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- grayscale: Show in grayscale (default: false)
|
||||
|
||||
Methods: visa, mastercard, amex, paypal, apple_pay, google_pay, discover, klarna
|
||||
|
||||
Usage:
|
||||
{{ payment_icons() }}
|
||||
{{ payment_icons(methods=['visa', 'mastercard', 'paypal'], size='lg') }}
|
||||
#}
|
||||
{% macro payment_icons(
|
||||
methods=none,
|
||||
size='md',
|
||||
grayscale=false
|
||||
) %}
|
||||
{% set all_methods = {
|
||||
'visa': {'name': 'Visa', 'color': 'text-blue-600'},
|
||||
'mastercard': {'name': 'Mastercard', 'color': 'text-orange-500'},
|
||||
'amex': {'name': 'American Express', 'color': 'text-blue-500'},
|
||||
'paypal': {'name': 'PayPal', 'color': 'text-blue-700'},
|
||||
'apple_pay': {'name': 'Apple Pay', 'color': 'text-gray-900 dark:text-white'},
|
||||
'google_pay': {'name': 'Google Pay', 'color': 'text-gray-700'},
|
||||
'discover': {'name': 'Discover', 'color': 'text-orange-600'},
|
||||
'klarna': {'name': 'Klarna', 'color': 'text-pink-500'}
|
||||
} %}
|
||||
{% set selected = methods if methods else ['visa', 'mastercard', 'amex', 'paypal'] %}
|
||||
{% set sizes = {
|
||||
'xs': 'h-4',
|
||||
'sm': 'h-6',
|
||||
'md': 'h-8',
|
||||
'lg': 'h-10'
|
||||
} %}
|
||||
|
||||
<div class="flex items-center gap-2 {{ 'grayscale opacity-60' if grayscale else '' }}">
|
||||
{% for method in selected %}
|
||||
{% if method in all_methods %}
|
||||
<div
|
||||
class="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded {{ sizes[size] }} flex items-center justify-center"
|
||||
title="{{ all_methods[method].name }}"
|
||||
>
|
||||
<span class="text-xs font-bold {{ all_methods[method].color }}">{{ all_methods[method].name[:4] }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Guarantee Badge
|
||||
===============
|
||||
Individual guarantee/warranty badge.
|
||||
|
||||
Parameters:
|
||||
- type: 'money_back' | 'warranty' | 'authentic' | 'satisfaction' (default: 'money_back')
|
||||
- days: Number of days (for money_back/warranty)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- variant: 'default' | 'outlined' | 'filled' (default: 'default')
|
||||
|
||||
Usage:
|
||||
{{ guarantee_badge(type='money_back', days=30) }}
|
||||
{{ guarantee_badge(type='warranty', days=365, variant='filled') }}
|
||||
#}
|
||||
{% macro guarantee_badge(
|
||||
type='money_back',
|
||||
days=30,
|
||||
size='md',
|
||||
variant='default'
|
||||
) %}
|
||||
{% set badges = {
|
||||
'money_back': {
|
||||
'icon': 'cash',
|
||||
'title': days ~ '-Day Money Back',
|
||||
'subtitle': 'Guarantee'
|
||||
},
|
||||
'warranty': {
|
||||
'icon': 'shield-check',
|
||||
'title': (days // 365) ~ '-Year Warranty' if days >= 365 else days ~ '-Day Warranty',
|
||||
'subtitle': 'Included'
|
||||
},
|
||||
'authentic': {
|
||||
'icon': 'badge-check',
|
||||
'title': '100% Authentic',
|
||||
'subtitle': 'Guaranteed'
|
||||
},
|
||||
'satisfaction': {
|
||||
'icon': 'emoji-happy',
|
||||
'title': 'Satisfaction',
|
||||
'subtitle': 'Guaranteed'
|
||||
}
|
||||
} %}
|
||||
{% set badge = badges[type] %}
|
||||
{% set sizes = {
|
||||
'sm': {'icon': 'w-8 h-8', 'title': 'text-xs', 'subtitle': 'text-xs', 'padding': 'p-3'},
|
||||
'md': {'icon': 'w-10 h-10', 'title': 'text-sm', 'subtitle': 'text-xs', 'padding': 'p-4'},
|
||||
'lg': {'icon': 'w-12 h-12', 'title': 'text-base', 'subtitle': 'text-sm', 'padding': 'p-5'}
|
||||
} %}
|
||||
{% set variants = {
|
||||
'default': 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700',
|
||||
'outlined': 'border-2 border-purple-600 dark:border-purple-400',
|
||||
'filled': 'bg-purple-600 text-white'
|
||||
} %}
|
||||
|
||||
<div class="{{ variants[variant] }} rounded-lg {{ sizes[size].padding }} text-center">
|
||||
<div class="mx-auto mb-2 {{ 'text-purple-600 dark:text-purple-400' if variant != 'filled' else 'text-white' }}">
|
||||
<span x-html="$icon('{{ badge.icon }}', '{{ sizes[size].icon }} mx-auto')"></span>
|
||||
</div>
|
||||
<p class="font-bold {{ sizes[size].title }} {{ 'text-gray-900 dark:text-white' if variant != 'filled' else 'text-white' }}">{{ badge.title }}</p>
|
||||
<p class="{{ sizes[size].subtitle }} {{ 'text-gray-500 dark:text-gray-400' if variant != 'filled' else 'text-purple-100' }}">{{ badge.subtitle }}</p>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Security Seals
|
||||
==============
|
||||
Security certification seals.
|
||||
|
||||
Parameters:
|
||||
- seals: List of seal types to show
|
||||
- layout: 'horizontal' | 'vertical' (default: 'horizontal')
|
||||
|
||||
Seal Types: ssl, pci, mcafee, norton, trustpilot
|
||||
|
||||
Usage:
|
||||
{{ security_seals(seals=['ssl', 'pci']) }}
|
||||
#}
|
||||
{% macro security_seals(
|
||||
seals=none,
|
||||
layout='horizontal'
|
||||
) %}
|
||||
{% set all_seals = [
|
||||
{'id': 'ssl', 'name': 'SSL Secure', 'icon': 'lock-closed'},
|
||||
{'id': 'pci', 'name': 'PCI Compliant', 'icon': 'shield-check'},
|
||||
{'id': 'verified', 'name': 'Verified Business', 'icon': 'badge-check'},
|
||||
{'id': 'secure_checkout', 'name': 'Secure Checkout', 'icon': 'shield-check'}
|
||||
] %}
|
||||
{% set selected = seals if seals else ['ssl', 'verified'] %}
|
||||
|
||||
<div class="flex {{ 'flex-col' if layout == 'vertical' else 'flex-wrap' }} items-center gap-3">
|
||||
{% for seal_id in selected %}
|
||||
{% for seal in all_seals %}
|
||||
{% if seal.id == seal_id %}
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<span x-html="$icon('{{ seal.icon }}', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
<span class="text-sm font-medium text-green-700 dark:text-green-300">{{ seal.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Checkout Trust Section
|
||||
======================
|
||||
Combined trust elements for checkout pages.
|
||||
|
||||
Parameters:
|
||||
- show_guarantee: Show money-back guarantee (default: true)
|
||||
- show_payment: Show payment icons (default: true)
|
||||
- show_security: Show security seals (default: true)
|
||||
|
||||
Usage:
|
||||
{{ checkout_trust_section() }}
|
||||
#}
|
||||
{% macro checkout_trust_section(
|
||||
show_guarantee=true,
|
||||
show_payment=true,
|
||||
show_security=true
|
||||
) %}
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 space-y-6">
|
||||
{% if show_guarantee %}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('shield-check', 'w-8 h-8 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">100% Secure Checkout</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Your payment information is encrypted and secure. We never store your card details.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_payment %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">We accept:</p>
|
||||
{{ payment_icons(size='md') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_security %}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{{ security_seals() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
|
||||
256-bit SSL
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
30-day returns
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('support', 'w-4 h-4')"></span>
|
||||
24/7 support
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
418
app/templates/shared/macros/storefront/variant-selector.html
Normal file
418
app/templates/shared/macros/storefront/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