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>
409 lines
15 KiB
HTML
409 lines
15 KiB
HTML
{#
|
|
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 %}
|