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:
2026-01-30 22:58:28 +01:00
parent 9decb9c29e
commit 7245f79f7b
62 changed files with 94 additions and 94 deletions

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 &quot;' + {{ cart_var }}.promo_code + '&quot; 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 %}

View 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 %}

View File

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

View File

@@ -0,0 +1,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 %}

View File

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

View File

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

View File

@@ -0,0 +1,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 %}

View 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 %}

View 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 %}

View 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 %}

View File

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