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:
209
app/templates/shared/macros/shop/add-to-cart.html
Normal file
209
app/templates/shared/macros/shop/add-to-cart.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{#
|
||||
Add to Cart Components
|
||||
======================
|
||||
Standardized add-to-cart functionality with quantity selector.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/add-to-cart.html' import add_to_cart_button, add_to_cart_form %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Add to Cart Button
|
||||
==================
|
||||
Simple add to cart button with loading state.
|
||||
|
||||
Parameters:
|
||||
- product_id_var: Alpine.js expression for product ID (default: 'product.id')
|
||||
- variant_id_var: Alpine.js expression for variant ID (default: 'selectedVariant?.id')
|
||||
- quantity_var: Alpine.js variable for quantity (default: 'quantity')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'addingToCart')
|
||||
- stock_var: Alpine.js expression for stock (default: 'product.stock')
|
||||
- action: Alpine.js action to execute (default: 'addToCart()')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- full_width: Make button full width (default: true)
|
||||
- show_icon: Show cart icon (default: true)
|
||||
- label: Button label (default: 'Add to Cart')
|
||||
- loading_label: Label while loading (default: 'Adding...')
|
||||
|
||||
Usage:
|
||||
{{ add_to_cart_button() }}
|
||||
{{ add_to_cart_button(size='lg', label='Buy Now') }}
|
||||
#}
|
||||
{% macro add_to_cart_button(
|
||||
product_id_var='product.id',
|
||||
variant_id_var='selectedVariant?.id',
|
||||
quantity_var='quantity',
|
||||
loading_var='addingToCart',
|
||||
stock_var='product.stock',
|
||||
action='addToCart()',
|
||||
size='md',
|
||||
full_width=true,
|
||||
show_icon=true,
|
||||
label='Add to Cart',
|
||||
loading_label='Adding...'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'px-3 py-1.5 text-sm', 'icon': 'w-4 h-4'},
|
||||
'md': {'btn': 'px-4 py-2.5 text-base', 'icon': 'w-5 h-5'},
|
||||
'lg': {'btn': 'px-6 py-3 text-lg', 'icon': 'w-6 h-6'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ action }}"
|
||||
:disabled="{{ loading_var }} || {{ stock_var }} === 0"
|
||||
class="{{ s.btn }} {{ 'w-full' if full_width else '' }} font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{# Loading Spinner #}
|
||||
<span x-show="{{ loading_var }}" x-html="$icon('spinner', '{{ s.icon }}')"></span>
|
||||
|
||||
{# Cart Icon #}
|
||||
{% if show_icon %}
|
||||
<span x-show="!{{ loading_var }}" x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
{% endif %}
|
||||
|
||||
{# Label #}
|
||||
<span x-text="{{ loading_var }} ? '{{ loading_label }}' : ({{ stock_var }} === 0 ? 'Out of Stock' : '{{ label }}')"></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Add to Cart Form
|
||||
================
|
||||
Complete add to cart section with quantity selector.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable for product (default: 'product')
|
||||
- quantity_var: Alpine.js variable for quantity (default: 'quantity')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'addingToCart')
|
||||
- action: Alpine.js action to execute (default: 'addToCart()')
|
||||
- show_stock: Show stock status (default: true)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ add_to_cart_form() }}
|
||||
#}
|
||||
{% macro add_to_cart_form(
|
||||
product_var='product',
|
||||
quantity_var='quantity',
|
||||
loading_var='addingToCart',
|
||||
action='addToCart()',
|
||||
show_stock=true,
|
||||
size='md'
|
||||
) %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="space-y-4">
|
||||
{# Stock Status #}
|
||||
{% if show_stock %}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm"
|
||||
:class="{{ product_var }}.stock > 10 ? 'text-green-600 dark:text-green-400' : {{ product_var }}.stock > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
<span x-html="$icon({{ product_var }}.stock > 0 ? 'check-circle' : 'x-circle', 'w-4 h-4')"></span>
|
||||
<span x-text="{{ product_var }}.stock > 10 ? 'In Stock' : {{ product_var }}.stock > 0 ? 'Only ' + {{ product_var }}.stock + ' left' : 'Out of Stock'"></span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quantity and Add Button #}
|
||||
<div class="flex items-center gap-4">
|
||||
{# Quantity Selector #}
|
||||
<div class="flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Quantity</label>
|
||||
{{ number_stepper(model=quantity_var, min=1, max=product_var ~ '.stock', size=size, disabled_var=loading_var) }}
|
||||
</div>
|
||||
|
||||
{# Add to Cart Button #}
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 invisible">Action</label>
|
||||
{{ add_to_cart_button(
|
||||
stock_var=product_var ~ '.stock',
|
||||
loading_var=loading_var,
|
||||
action=action,
|
||||
size=size,
|
||||
full_width=true
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Success Message #}
|
||||
<div
|
||||
x-show="addedToCart"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="flex items-center gap-2 text-green-600 dark:text-green-400 text-sm"
|
||||
>
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
<span>Added to cart!</span>
|
||||
<a href="/cart" class="underline hover:no-underline">View Cart</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Buy Now Button
|
||||
==============
|
||||
Direct checkout button (skips cart).
|
||||
|
||||
Parameters:
|
||||
- action: Alpine.js action (default: 'buyNow()')
|
||||
- loading_var: Loading state variable (default: 'buyingNow')
|
||||
- stock_var: Stock expression (default: 'product.stock')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- full_width: Full width button (default: true)
|
||||
|
||||
Usage:
|
||||
{{ buy_now_button() }}
|
||||
#}
|
||||
{% macro buy_now_button(
|
||||
action='buyNow()',
|
||||
loading_var='buyingNow',
|
||||
stock_var='product.stock',
|
||||
size='md',
|
||||
full_width=true
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'px-3 py-1.5 text-sm', 'icon': 'w-4 h-4'},
|
||||
'md': {'btn': 'px-4 py-2.5 text-base', 'icon': 'w-5 h-5'},
|
||||
'lg': {'btn': 'px-6 py-3 text-lg', 'icon': 'w-6 h-6'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ action }}"
|
||||
:disabled="{{ loading_var }} || {{ stock_var }} === 0"
|
||||
class="{{ s.btn }} {{ 'w-full' if full_width else '' }} font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 border border-purple-200 dark:border-purple-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<span x-show="{{ loading_var }}" x-html="$icon('spinner', '{{ s.icon }}')"></span>
|
||||
<span x-show="!{{ loading_var }}" x-html="$icon('lightning-bolt', '{{ s.icon }}')"></span>
|
||||
<span>Buy Now</span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Quantity Selector (Shop variant)
|
||||
================================
|
||||
Shop-specific quantity selector with stock validation.
|
||||
|
||||
Parameters:
|
||||
- model: Alpine.js model for quantity (required)
|
||||
- max_var: Alpine.js expression for max stock (default: 'product.stock')
|
||||
- disabled_var: Alpine.js variable for disabled state (default: none)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ shop_quantity_selector(model='quantity', max_var='product.stock') }}
|
||||
#}
|
||||
{% macro shop_quantity_selector(model, max_var='product.stock', disabled_var=none, size='md') %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
{{ number_stepper(model=model, min=1, max=max_var, size=size, disabled_var=disabled_var, label='Quantity') }}
|
||||
{% endmacro %}
|
||||
408
app/templates/shared/macros/shop/mini-cart.html
Normal file
408
app/templates/shared/macros/shop/mini-cart.html
Normal file
@@ -0,0 +1,408 @@
|
||||
{#
|
||||
Mini Cart Components
|
||||
====================
|
||||
Cart preview dropdown and cart item components.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/mini-cart.html' import mini_cart, cart_icon_button, cart_item %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Cart Icon Button
|
||||
================
|
||||
Cart icon with item count badge for header.
|
||||
|
||||
Parameters:
|
||||
- cart_count_var: Alpine.js expression for cart item count (default: 'cart.items.length')
|
||||
- toggle_action: Alpine.js action to toggle cart dropdown (default: 'toggleCart()')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ cart_icon_button() }}
|
||||
#}
|
||||
{% macro cart_icon_button(
|
||||
cart_count_var='cart.items.length',
|
||||
toggle_action='toggleCart()',
|
||||
size='md'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'btn': 'p-1.5', 'icon': 'w-5 h-5', 'badge': 'w-4 h-4 text-xs -top-1 -right-1'},
|
||||
'md': {'btn': 'p-2', 'icon': 'w-6 h-6', 'badge': 'w-5 h-5 text-xs -top-1.5 -right-1.5'},
|
||||
'lg': {'btn': 'p-2.5', 'icon': 'w-7 h-7', 'badge': 'w-6 h-6 text-sm -top-2 -right-2'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ toggle_action }}"
|
||||
class="relative {{ s.btn }} text-gray-600 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Shopping cart"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
|
||||
{# Badge #}
|
||||
<span
|
||||
x-show="{{ cart_count_var }} > 0"
|
||||
x-text="{{ cart_count_var }}"
|
||||
class="absolute {{ s.badge }} flex items-center justify-center font-bold text-white bg-purple-600 dark:bg-purple-500 rounded-full"
|
||||
></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mini Cart Dropdown
|
||||
==================
|
||||
Cart preview dropdown showing recent items.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Alpine.js variable for cart object (default: 'cart')
|
||||
- show_var: Alpine.js variable for dropdown visibility (default: 'showCart')
|
||||
- max_items: Maximum items to show (default: 3)
|
||||
- cart_url: URL to full cart page (default: '/cart')
|
||||
- checkout_url: URL to checkout (default: '/checkout')
|
||||
|
||||
Expected cart object structure:
|
||||
{
|
||||
items: [
|
||||
{id, product_id, name, image_url, price, quantity, variant_name}
|
||||
],
|
||||
subtotal: number,
|
||||
item_count: number
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ mini_cart() }}
|
||||
#}
|
||||
{% macro mini_cart(
|
||||
cart_var='cart',
|
||||
show_var='showCart',
|
||||
max_items=3,
|
||||
cart_url='/cart',
|
||||
checkout_url='/checkout'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
@click.away="{{ show_var }} = false"
|
||||
class="absolute right-0 top-full mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Shopping Cart</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="{{ cart_var }}.item_count + ' item' + ({{ cart_var }}.item_count !== 1 ? 's' : '')"></p>
|
||||
</div>
|
||||
|
||||
{# Empty State #}
|
||||
<div x-show="{{ cart_var }}.items.length === 0" class="px-4 py-8 text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600">
|
||||
<span x-html="$icon('shopping-cart', 'w-full h-full')"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Your cart is empty</p>
|
||||
<a href="/shop" class="inline-block mt-3 text-sm text-purple-600 dark:text-purple-400 hover:underline">
|
||||
Continue Shopping
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Cart Items #}
|
||||
<div x-show="{{ cart_var }}.items.length > 0" class="max-h-64 overflow-y-auto">
|
||||
<template x-for="(item, index) in {{ cart_var }}.items.slice(0, {{ max_items }})" :key="item.id">
|
||||
{{ cart_item_mini(cart_var=cart_var) }}
|
||||
</template>
|
||||
|
||||
{# More Items Notice #}
|
||||
<div
|
||||
x-show="{{ cart_var }}.items.length > {{ max_items }}"
|
||||
class="px-4 py-2 text-center text-xs text-gray-500 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span x-text="'+ ' + ({{ cart_var }}.items.length - {{ max_items }}) + ' more item' + ({{ cart_var }}.items.length - {{ max_items }} !== 1 ? 's' : '')"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div x-show="{{ cart_var }}.items.length > 0" class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Subtotal #}
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.subtotal.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="{{ cart_url }}"
|
||||
class="block w-full px-4 py-2 text-center text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg transition-colors"
|
||||
>
|
||||
View Cart
|
||||
</a>
|
||||
<a
|
||||
href="{{ checkout_url }}"
|
||||
class="block w-full px-4 py-2 text-center text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors"
|
||||
>
|
||||
Checkout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Item (Mini version)
|
||||
========================
|
||||
Compact cart item for mini cart dropdown.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Cart variable for remove action (default: 'cart')
|
||||
|
||||
Usage:
|
||||
<template x-for="item in cart.items" :key="item.id">
|
||||
{{ cart_item_mini() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro cart_item_mini(cart_var='cart') %}
|
||||
<div class="flex gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
{# Image #}
|
||||
<a :href="item.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="w-14 h-14 object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Details #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<a :href="item.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="item.name"></a>
|
||||
</h4>
|
||||
<p x-show="item.variant_name" class="text-xs text-gray-500 dark:text-gray-400" x-text="item.variant_name"></p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'Qty: ' + item.quantity"></span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" x-text="'€' + (item.price * item.quantity).toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Remove Button #}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFromCart(item.id)"
|
||||
class="flex-shrink-0 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
:aria-label="'Remove ' + item.name + ' from cart'"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Item (Full version)
|
||||
========================
|
||||
Full cart item for cart page with quantity controls.
|
||||
|
||||
Parameters:
|
||||
- item_var: Variable name for item (default: 'item')
|
||||
- index_var: Variable name for index (default: 'index')
|
||||
- show_image: Show product image (default: true)
|
||||
- editable: Allow quantity editing (default: true)
|
||||
|
||||
Usage:
|
||||
<template x-for="(item, index) in cart.items" :key="item.id">
|
||||
{{ cart_item() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro cart_item(
|
||||
item_var='item',
|
||||
index_var='index',
|
||||
show_image=true,
|
||||
editable=true
|
||||
) %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="flex gap-4 py-4 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
{# Image #}
|
||||
{% if show_image %}
|
||||
<a :href="{{ item_var }}.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="{{ item_var }}.image_url"
|
||||
:alt="{{ item_var }}.name"
|
||||
class="w-20 h-20 md:w-24 md:h-24 object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Details #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">
|
||||
<a :href="{{ item_var }}.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ item_var }}.name"></a>
|
||||
</h3>
|
||||
<p x-show="{{ item_var }}.variant_name" class="text-sm text-gray-500 dark:text-gray-400" x-text="{{ item_var }}.variant_name"></p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="'€' + {{ item_var }}.price.toFixed(2) + ' each'"></p>
|
||||
</div>
|
||||
|
||||
{# Remove Button #}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFromCart({{ item_var }}.id)"
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
:aria-label="'Remove ' + {{ item_var }}.name"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Quantity and Total #}
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
{% if editable %}
|
||||
<div>
|
||||
{{ number_stepper(
|
||||
model=item_var ~ '.quantity',
|
||||
min=1,
|
||||
max=item_var ~ '.max_quantity',
|
||||
size='sm',
|
||||
label='Quantity'
|
||||
) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="'Qty: ' + {{ item_var }}.quantity"></span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100" x-text="'€' + ({{ item_var }}.price * {{ item_var }}.quantity).toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{# Low Stock Warning #}
|
||||
<div
|
||||
x-show="{{ item_var }}.max_quantity && {{ item_var }}.max_quantity <= 5"
|
||||
class="flex items-center gap-1 mt-2 text-orange-600 dark:text-orange-400 text-xs"
|
||||
>
|
||||
<span x-html="$icon('exclamation', 'w-4 h-4')"></span>
|
||||
<span x-text="'Only ' + {{ item_var }}.max_quantity + ' left in stock'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Cart Summary
|
||||
============
|
||||
Order summary with totals and checkout button.
|
||||
|
||||
Parameters:
|
||||
- cart_var: Alpine.js variable for cart (default: 'cart')
|
||||
- show_promo: Show promo code input (default: true)
|
||||
- show_shipping: Show shipping estimate (default: true)
|
||||
- checkout_url: Checkout URL (default: '/checkout')
|
||||
|
||||
Expected cart structure:
|
||||
{
|
||||
subtotal: number,
|
||||
discount: number,
|
||||
shipping: number,
|
||||
tax: number,
|
||||
total: number,
|
||||
promo_code: string | null
|
||||
}
|
||||
|
||||
Usage:
|
||||
{{ cart_summary() }}
|
||||
#}
|
||||
{% macro cart_summary(
|
||||
cart_var='cart',
|
||||
show_promo=true,
|
||||
show_shipping=true,
|
||||
checkout_url='/checkout'
|
||||
) %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Order Summary</h2>
|
||||
|
||||
{# Promo Code #}
|
||||
{% if show_promo %}
|
||||
<div class="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Promo Code</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="promoCode"
|
||||
placeholder="Enter code"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="applyPromoCode()"
|
||||
:disabled="!promoCode || applyingPromo"
|
||||
class="px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="{{ cart_var }}.promo_code" class="mt-2 text-sm text-green-600 dark:text-green-400" x-text="'Code "' + {{ cart_var }}.promo_code + '" applied'"></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Totals #}
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.subtotal.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="{{ cart_var }}.discount > 0" class="flex justify-between text-green-600 dark:text-green-400">
|
||||
<span>Discount</span>
|
||||
<span x-text="'-€' + {{ cart_var }}.discount.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
{% if show_shipping %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="{{ cart_var }}.shipping > 0 ? '€' + {{ cart_var }}.shipping.toFixed(2) : 'Free'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div x-show="{{ cart_var }}.tax > 0" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Tax</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.tax.toFixed(2)"></span>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span class="text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="'€' + {{ cart_var }}.total.toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Checkout Button #}
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href="{{ checkout_url }}"
|
||||
class="block w-full px-6 py-3 text-center text-base font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
|
||||
{# Trust Badges #}
|
||||
<div class="mt-4 flex items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
|
||||
Secure Checkout
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4')"></span>
|
||||
SSL Encrypted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
253
app/templates/shared/macros/shop/product-card.html
Normal file
253
app/templates/shared/macros/shop/product-card.html
Normal file
@@ -0,0 +1,253 @@
|
||||
{#
|
||||
Product Card
|
||||
============
|
||||
A versatile product card component for e-commerce listings.
|
||||
Supports multiple sizes, badges, ratings, and quick actions.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable name for the product object (default: 'product')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- show_rating: Show star rating (default: true)
|
||||
- show_quick_add: Show quick add to cart button (default: true)
|
||||
- show_wishlist: Show wishlist heart icon (default: true)
|
||||
- show_vendor: Show vendor name for marketplace (default: false)
|
||||
- add_to_cart_action: Alpine.js action for add to cart (default: 'addToCart(product)')
|
||||
- wishlist_action: Alpine.js action for wishlist toggle (default: 'toggleWishlist(product)')
|
||||
- product_url_field: Field name for product URL (default: 'url')
|
||||
- image_field: Field name for image URL (default: 'image_url')
|
||||
- title_field: Field name for product title (default: 'name')
|
||||
- price_field: Field name for price (default: 'price')
|
||||
- sale_price_field: Field name for sale price (default: 'sale_price')
|
||||
- rating_field: Field name for rating (default: 'rating')
|
||||
- review_count_field: Field name for review count (default: 'review_count')
|
||||
- stock_field: Field name for stock quantity (default: 'stock')
|
||||
- vendor_field: Field name for vendor name (default: 'vendor_name')
|
||||
|
||||
Expected product object structure:
|
||||
{
|
||||
id: number,
|
||||
name: string,
|
||||
url: string,
|
||||
image_url: string,
|
||||
price: number,
|
||||
sale_price: number | null,
|
||||
rating: number (0-5),
|
||||
review_count: number,
|
||||
stock: number,
|
||||
is_new: boolean,
|
||||
vendor_name: string (optional)
|
||||
}
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/product-card.html' import product_card %}
|
||||
|
||||
<template x-for="product in products" :key="product.id">
|
||||
{{ product_card() }}
|
||||
</template>
|
||||
|
||||
{# With custom settings #}
|
||||
{{ product_card(product_var='featuredProduct', size='lg', show_vendor=true) }}
|
||||
#}
|
||||
|
||||
{% macro product_card(
|
||||
product_var='product',
|
||||
size='md',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
show_wishlist=true,
|
||||
show_vendor=false,
|
||||
add_to_cart_action='addToCart(product)',
|
||||
wishlist_action='toggleWishlist(product)',
|
||||
product_url_field='url',
|
||||
image_field='image_url',
|
||||
title_field='name',
|
||||
price_field='price',
|
||||
sale_price_field='sale_price',
|
||||
rating_field='rating',
|
||||
review_count_field='review_count',
|
||||
stock_field='stock',
|
||||
vendor_field='vendor_name'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {
|
||||
'card': 'max-w-[200px]',
|
||||
'image': 'h-32',
|
||||
'title': 'text-sm',
|
||||
'price': 'text-sm',
|
||||
'badge': 'text-xs px-1.5 py-0.5',
|
||||
'btn': 'text-xs px-2 py-1',
|
||||
'icon': 'w-4 h-4',
|
||||
'rating': 'w-3 h-3'
|
||||
},
|
||||
'md': {
|
||||
'card': 'max-w-[280px]',
|
||||
'image': 'h-48',
|
||||
'title': 'text-base',
|
||||
'price': 'text-base',
|
||||
'badge': 'text-xs px-2 py-1',
|
||||
'btn': 'text-sm px-3 py-2',
|
||||
'icon': 'w-5 h-5',
|
||||
'rating': 'w-4 h-4'
|
||||
},
|
||||
'lg': {
|
||||
'card': 'max-w-[360px]',
|
||||
'image': 'h-64',
|
||||
'title': 'text-lg',
|
||||
'price': 'text-lg',
|
||||
'badge': 'text-sm px-2.5 py-1',
|
||||
'btn': 'text-base px-4 py-2.5',
|
||||
'icon': 'w-6 h-6',
|
||||
'rating': 'w-5 h-5'
|
||||
}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<div class="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden {{ s.card }}">
|
||||
{# Image Container #}
|
||||
<div class="relative {{ s.image }} overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<a :href="{{ product_var }}.{{ product_url_field }}">
|
||||
<img
|
||||
:src="{{ product_var }}.{{ image_field }}"
|
||||
:alt="{{ product_var }}.{{ title_field }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Badges #}
|
||||
<div class="absolute top-2 left-2 flex flex-col gap-1">
|
||||
{# Sale Badge #}
|
||||
<span
|
||||
x-show="{{ product_var }}.{{ sale_price_field }}"
|
||||
class="{{ s.badge }} font-semibold text-white bg-red-500 rounded"
|
||||
>
|
||||
<span x-text="'-' + Math.round((1 - {{ product_var }}.{{ sale_price_field }} / {{ product_var }}.{{ price_field }}) * 100) + '%'"></span>
|
||||
</span>
|
||||
{# New Badge #}
|
||||
<span
|
||||
x-show="{{ product_var }}.is_new"
|
||||
class="{{ s.badge }} font-semibold text-white bg-green-500 rounded"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Wishlist Button #}
|
||||
{% if show_wishlist %}
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="{{ wishlist_action.replace('product', product_var) }}"
|
||||
class="absolute top-2 right-2 p-1.5 rounded-full bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 transition-colors opacity-0 group-hover:opacity-100"
|
||||
:class="{{ product_var }}.in_wishlist ? 'text-red-500' : 'text-gray-400 hover:text-red-500'"
|
||||
:aria-label="{{ product_var }}.in_wishlist ? 'Remove from wishlist' : 'Add to wishlist'"
|
||||
>
|
||||
<span x-html="$icon('heart', '{{ s.icon }}')" :class="{{ product_var }}.in_wishlist && 'fill-current'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Out of Stock Overlay #}
|
||||
<div
|
||||
x-show="{{ product_var }}.{{ stock_field }} === 0"
|
||||
class="absolute inset-0 bg-black/50 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white font-semibold {{ s.badge }} bg-gray-900 rounded">Out of Stock</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="p-3">
|
||||
{# Vendor Name #}
|
||||
{% if show_vendor %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1" x-text="{{ product_var }}.{{ vendor_field }}"></p>
|
||||
{% endif %}
|
||||
|
||||
{# Title #}
|
||||
<h3 class="{{ s.title }} font-medium text-gray-900 dark:text-gray-100 mb-1 line-clamp-2">
|
||||
<a :href="{{ product_var }}.{{ product_url_field }}" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ product_var }}.{{ title_field }}"></a>
|
||||
</h3>
|
||||
|
||||
{# Rating #}
|
||||
{% if show_rating %}
|
||||
<div class="flex items-center gap-1 mb-2" x-show="{{ product_var }}.{{ rating_field }}">
|
||||
<div class="flex">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
x-html="$icon('star', '{{ s.rating }}')"
|
||||
:class="i <= Math.round({{ product_var }}.{{ rating_field }}) ? 'text-yellow-400 fill-current' : 'text-gray-300 dark:text-gray-600'"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'(' + {{ product_var }}.{{ review_count_field }} + ')'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Price #}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
class="{{ s.price }} font-bold"
|
||||
:class="{{ product_var }}.{{ sale_price_field }} ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'"
|
||||
x-text="'€' + ({{ product_var }}.{{ sale_price_field }} || {{ product_var }}.{{ price_field }}).toFixed(2)"
|
||||
></span>
|
||||
<span
|
||||
x-show="{{ product_var }}.{{ sale_price_field }}"
|
||||
class="text-sm text-gray-400 line-through"
|
||||
x-text="'€' + {{ product_var }}.{{ price_field }}.toFixed(2)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
{# Quick Add Button #}
|
||||
{% if show_quick_add %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ add_to_cart_action.replace('product', product_var) }}"
|
||||
:disabled="{{ product_var }}.{{ stock_field }} === 0"
|
||||
class="w-full {{ s.btn }} font-medium text-white bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', '{{ s.icon }}')"></span>
|
||||
<span x-text="{{ product_var }}.{{ stock_field }} === 0 ? 'Out of Stock' : 'Add to Cart'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product Badge
|
||||
=============
|
||||
Standalone badge component for product overlays.
|
||||
|
||||
Parameters:
|
||||
- type: 'sale' | 'new' | 'bestseller' | 'low_stock' | 'out_of_stock'
|
||||
- value: Optional value (e.g., '-20%' for sale, 'Only 3 left' for low_stock)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ product_badge(type='sale', value='-20%') }}
|
||||
{{ product_badge(type='new') }}
|
||||
{{ product_badge(type='low_stock', value='Only 3 left') }}
|
||||
#}
|
||||
{% macro product_badge(type, value=none, size='md') %}
|
||||
{% set badge_styles = {
|
||||
'sale': 'bg-red-500 text-white',
|
||||
'new': 'bg-green-500 text-white',
|
||||
'bestseller': 'bg-yellow-500 text-gray-900',
|
||||
'low_stock': 'bg-orange-500 text-white',
|
||||
'out_of_stock': 'bg-gray-700 text-white'
|
||||
} %}
|
||||
{% set badge_labels = {
|
||||
'sale': 'Sale',
|
||||
'new': 'New',
|
||||
'bestseller': 'Bestseller',
|
||||
'low_stock': 'Low Stock',
|
||||
'out_of_stock': 'Out of Stock'
|
||||
} %}
|
||||
{% set sizes = {
|
||||
'sm': 'text-xs px-1.5 py-0.5',
|
||||
'md': 'text-xs px-2 py-1',
|
||||
'lg': 'text-sm px-2.5 py-1'
|
||||
} %}
|
||||
<span class="{{ sizes[size] }} font-semibold {{ badge_styles[type] }} rounded">
|
||||
{{ value if value else badge_labels[type] }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
215
app/templates/shared/macros/shop/product-grid.html
Normal file
215
app/templates/shared/macros/shop/product-grid.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{#
|
||||
Product Grid
|
||||
============
|
||||
Responsive grid layout for product listings.
|
||||
Includes loading skeletons and empty state.
|
||||
|
||||
Parameters:
|
||||
- products_var: Alpine.js variable containing products array (default: 'products')
|
||||
- loading_var: Alpine.js variable for loading state (default: 'loading')
|
||||
- columns: Dict with breakpoint column counts (default: {sm: 2, md: 3, lg: 4})
|
||||
- gap: Gap size 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- card_size: Product card size 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- show_rating: Pass to product cards (default: true)
|
||||
- show_quick_add: Pass to product cards (default: true)
|
||||
- show_wishlist: Pass to product cards (default: true)
|
||||
- show_vendor: Pass to product cards (default: false)
|
||||
- empty_title: Title for empty state (default: 'No products found')
|
||||
- empty_message: Message for empty state (default: 'Try adjusting your filters')
|
||||
- empty_icon: Icon for empty state (default: 'shopping-bag')
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/product-grid.html' import product_grid, product_grid_skeleton %}
|
||||
|
||||
{{ product_grid() }}
|
||||
|
||||
{# With custom columns #}
|
||||
{{ product_grid(columns={'sm': 1, 'md': 2, 'lg': 3}) }}
|
||||
#}
|
||||
|
||||
{% macro product_grid(
|
||||
products_var='products',
|
||||
loading_var='loading',
|
||||
columns=none,
|
||||
gap='md',
|
||||
card_size='md',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
show_wishlist=true,
|
||||
show_vendor=false,
|
||||
empty_title='No products found',
|
||||
empty_message='Try adjusting your filters or search terms',
|
||||
empty_icon='shopping-bag'
|
||||
) %}
|
||||
{% from 'shared/macros/shop/product-card.html' import product_card %}
|
||||
|
||||
{% set cols = columns if columns else {'sm': 2, 'md': 3, 'lg': 4} %}
|
||||
{% set gaps = {'sm': 'gap-3', 'md': 'gap-4', 'lg': 'gap-6'} %}
|
||||
|
||||
{# Loading State #}
|
||||
<div x-show="{{ loading_var }}" class="grid grid-cols-{{ cols.sm }} md:grid-cols-{{ cols.md }} lg:grid-cols-{{ cols.lg }} {{ gaps[gap] }}">
|
||||
<template x-for="i in 8" :key="i">
|
||||
{{ product_skeleton(size=card_size) }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Empty State #}
|
||||
<div
|
||||
x-show="!{{ loading_var }} && {{ products_var }}.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 px-4"
|
||||
>
|
||||
<div class="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600">
|
||||
<span x-html="$icon('{{ empty_icon }}', 'w-full h-full')"></span>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">{{ empty_title }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm">{{ empty_message }}</p>
|
||||
</div>
|
||||
|
||||
{# Product Grid #}
|
||||
<div
|
||||
x-show="!{{ loading_var }} && {{ products_var }}.length > 0"
|
||||
class="grid grid-cols-{{ cols.sm }} md:grid-cols-{{ cols.md }} lg:grid-cols-{{ cols.lg }} {{ gaps[gap] }}"
|
||||
>
|
||||
<template x-for="product in {{ products_var }}" :key="product.id">
|
||||
{{ product_card(
|
||||
product_var='product',
|
||||
size=card_size,
|
||||
show_rating=show_rating,
|
||||
show_quick_add=show_quick_add,
|
||||
show_wishlist=show_wishlist,
|
||||
show_vendor=show_vendor
|
||||
) }}
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product Skeleton
|
||||
================
|
||||
Loading skeleton for a single product card.
|
||||
|
||||
Parameters:
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ product_skeleton() }}
|
||||
#}
|
||||
{% macro product_skeleton(size='md') %}
|
||||
{% set sizes = {
|
||||
'sm': {'card': 'max-w-[200px]', 'image': 'h-32'},
|
||||
'md': {'card': 'max-w-[280px]', 'image': 'h-48'},
|
||||
'lg': {'card': 'max-w-[360px]', 'image': 'h-64'}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden {{ s.card }} animate-pulse">
|
||||
{# Image Skeleton #}
|
||||
<div class="{{ s.image }} bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
{# Content Skeleton #}
|
||||
<div class="p-3 space-y-3">
|
||||
{# Title #}
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
|
||||
{# Rating #}
|
||||
<div class="flex gap-1">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Price #}
|
||||
<div class="h-5 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||
|
||||
{# Button #}
|
||||
<div class="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Product List Item
|
||||
=================
|
||||
Horizontal product card for list views.
|
||||
|
||||
Parameters:
|
||||
- product_var: Alpine.js variable name (default: 'product')
|
||||
- show_rating: Show star rating (default: true)
|
||||
- show_quick_add: Show add to cart button (default: true)
|
||||
- compact: Use compact spacing (default: false)
|
||||
|
||||
Usage:
|
||||
<template x-for="product in products" :key="product.id">
|
||||
{{ product_list_item() }}
|
||||
</template>
|
||||
#}
|
||||
{% macro product_list_item(
|
||||
product_var='product',
|
||||
show_rating=true,
|
||||
show_quick_add=true,
|
||||
compact=false
|
||||
) %}
|
||||
<div class="flex gap-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm p-{{ '3' if compact else '4' }} hover:shadow-md transition-shadow">
|
||||
{# Image #}
|
||||
<a :href="{{ product_var }}.url" class="flex-shrink-0">
|
||||
<img
|
||||
:src="{{ product_var }}.image_url"
|
||||
:alt="{{ product_var }}.name"
|
||||
class="w-{{ '20' if compact else '32' }} h-{{ '20' if compact else '32' }} object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
</a>
|
||||
|
||||
{# Content #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<a :href="{{ product_var }}.url" class="hover:text-purple-600 dark:hover:text-purple-400" x-text="{{ product_var }}.name"></a>
|
||||
</h3>
|
||||
|
||||
{% if show_rating %}
|
||||
<div class="flex items-center gap-1 mt-1" x-show="{{ product_var }}.rating">
|
||||
<div class="flex">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span
|
||||
x-html="$icon('star', 'w-3 h-3')"
|
||||
:class="i <= Math.round({{ product_var }}.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300 dark:text-gray-600'"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'(' + {{ product_var }}.review_count + ')'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
class="font-bold"
|
||||
:class="{{ product_var }}.sale_price ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'"
|
||||
x-text="'€' + ({{ product_var }}.sale_price || {{ product_var }}.price).toFixed(2)"
|
||||
></span>
|
||||
<span
|
||||
x-show="{{ product_var }}.sale_price"
|
||||
class="text-sm text-gray-400 line-through"
|
||||
x-text="'€' + {{ product_var }}.price.toFixed(2)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
{% if show_quick_add %}
|
||||
<div class="flex-shrink-0 self-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="addToCart({{ product_var }})"
|
||||
:disabled="{{ product_var }}.stock === 0"
|
||||
class="p-2 text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:aria-label="'Add ' + {{ product_var }}.name + ' to cart'"
|
||||
>
|
||||
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user