feat: add Priority 1 e-commerce shop macros

New macros in shared/macros/shop/:
- product-card.html: Product cards with badges, ratings, wishlist,
  quick-add, size variants (sm/md/lg)
- product-grid.html: Responsive grid with loading skeletons, empty state
- add-to-cart.html: Add to cart button/form, buy now, quantity selector
- mini-cart.html: Cart icon with badge, dropdown, items, summary

All components support:
- Dark mode via Tailwind dark: classes
- Vendor theming via CSS variables
- Alpine.js integration
- Accessible markup

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 17:04:12 +01:00
parent d863a26e6b
commit 2f64dba155
4 changed files with 1085 additions and 0 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,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,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 %}