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>
320 lines
13 KiB
HTML
320 lines
13 KiB
HTML
{# app/templates/storefront/cart.html #}
|
||
{% extends "storefront/base.html" %}
|
||
|
||
{% block title %}Shopping Cart{% endblock %}
|
||
|
||
{# Alpine.js component #}
|
||
{% block alpine_data %}shoppingCart(){% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
|
||
{# Breadcrumbs #}
|
||
<div class="breadcrumb mb-6">
|
||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||
<span>/</span>
|
||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||
<span>/</span>
|
||
<span class="text-gray-900 dark:text-gray-200 font-medium">Cart</span>
|
||
</div>
|
||
|
||
{# Page Header #}
|
||
<div class="mb-8">
|
||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
|
||
Shopping Cart
|
||
</h1>
|
||
</div>
|
||
|
||
{# Loading State #}
|
||
<div x-show="loading && items.length === 0" class="flex justify-center items-center py-12">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
|
||
{# Empty Cart #}
|
||
<div x-show="!loading && items.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||
<div class="text-6xl mb-4">🛒</div>
|
||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||
Your cart is empty
|
||
</h3>
|
||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||
Add some products to get started!
|
||
</p>
|
||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
|
||
Browse Products
|
||
</a>
|
||
</div>
|
||
|
||
{# Cart Items #}
|
||
<div x-show="items.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
|
||
{# Cart Items List #}
|
||
<div class="lg:col-span-2 space-y-4">
|
||
<template x-for="item in items" :key="item.product_id">
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||
<div class="flex gap-6">
|
||
{# Item Image #}
|
||
<div class="flex-shrink-0">
|
||
<img
|
||
:src="item.image_url || '/static/shop/img/placeholder.svg'"
|
||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||
:alt="item.name"
|
||
class="w-24 h-24 object-cover rounded-lg"
|
||
>
|
||
</div>
|
||
|
||
{# Item Details #}
|
||
<div class="flex-1">
|
||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-1" x-text="item.name"></h3>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2" x-text="'SKU: ' + item.sku"></p>
|
||
<p class="text-xl font-bold text-primary mb-4">
|
||
€<span x-text="parseFloat(item.price).toFixed(2)"></span>
|
||
</p>
|
||
|
||
{# Quantity Controls #}
|
||
{# noqa: FE-008 - Custom quantity stepper with async updateQuantity() per-item and :value binding #}
|
||
<div class="flex items-center gap-4">
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
@click="updateQuantity(item.product_id, item.quantity - 1)"
|
||
:disabled="item.quantity <= 1 || updating"
|
||
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
:value="item.quantity"
|
||
@change="updateQuantity(item.product_id, $event.target.value)"
|
||
min="1"
|
||
max="99"
|
||
:disabled="updating"
|
||
class="w-16 text-center px-2 py-1 border-2 border-gray-300 dark:border-gray-600 rounded font-semibold dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<button
|
||
@click="updateQuantity(item.product_id, item.quantity + 1)"
|
||
:disabled="updating"
|
||
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex-1 text-right">
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">Subtotal</p>
|
||
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||
€<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
@click="removeItem(item.product_id)"
|
||
:disabled="updating"
|
||
class="w-10 h-10 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-red-600 hover:border-red-600 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="Remove from cart"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
{# Cart Summary #}
|
||
<div class="lg:col-span-1">
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 sticky top-4">
|
||
<h3 class="text-xl font-semibold mb-6 pb-4 border-b-2 border-gray-200 dark:border-gray-700">
|
||
Order Summary
|
||
</h3>
|
||
|
||
<div class="space-y-3 mb-6">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600 dark:text-gray-400">Subtotal (<span x-text="totalItems"></span> items):</span>
|
||
<span class="font-semibold">€<span x-text="subtotal.toFixed(2)"></span></span>
|
||
</div>
|
||
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600 dark:text-gray-400">Shipping:</span>
|
||
<span class="font-semibold" x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
|
||
</div>
|
||
|
||
<div class="flex justify-between text-lg font-bold pt-3 border-t-2 border-gray-200 dark:border-gray-700">
|
||
<span>Total:</span>
|
||
<span class="text-primary text-2xl">€<span x-text="total.toFixed(2)"></span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
@click="proceedToCheckout()"
|
||
:disabled="updating || items.length === 0"
|
||
class="w-full px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-primary-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||
>
|
||
Proceed to Checkout
|
||
</button>
|
||
|
||
<a href="{{ base_url }}shop/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||
Continue Shopping
|
||
</a>
|
||
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||
Free shipping on orders over €50
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('shoppingCart', () => {
|
||
const baseData = shopLayoutData();
|
||
|
||
return {
|
||
...baseData,
|
||
|
||
items: [],
|
||
loading: false,
|
||
updating: false,
|
||
|
||
// Computed properties
|
||
get totalItems() {
|
||
return this.items.reduce((sum, item) => sum + item.quantity, 0);
|
||
},
|
||
|
||
get subtotal() {
|
||
return this.items.reduce((sum, item) =>
|
||
sum + (parseFloat(item.price) * item.quantity), 0
|
||
);
|
||
},
|
||
|
||
get shipping() {
|
||
// Free shipping over €50
|
||
return this.subtotal >= 50 ? 0 : 5.99;
|
||
},
|
||
|
||
get total() {
|
||
return this.subtotal + this.shipping;
|
||
},
|
||
|
||
// Initialize
|
||
async init() {
|
||
console.log('[SHOP] Cart page initializing...');
|
||
|
||
// Call parent init to set up sessionId
|
||
if (baseData.init) {
|
||
baseData.init.call(this);
|
||
}
|
||
|
||
await this.loadCart();
|
||
},
|
||
|
||
// Load cart from API
|
||
async loadCart() {
|
||
this.loading = true;
|
||
|
||
try {
|
||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
||
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
this.items = data.items || [];
|
||
this.cartCount = this.totalItems;
|
||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
||
}
|
||
} catch (error) {
|
||
console.error('[SHOP] Failed to load cart:', error);
|
||
this.showToast('Failed to load cart', 'error');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
// Update item quantity
|
||
async updateQuantity(productId, newQuantity) {
|
||
newQuantity = parseInt(newQuantity);
|
||
|
||
if (newQuantity < 1 || newQuantity > 99) return;
|
||
|
||
this.updating = true;
|
||
|
||
try {
|
||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
||
const response = await fetch(
|
||
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
|
||
{
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ quantity: newQuantity })
|
||
}
|
||
);
|
||
|
||
if (response.ok) {
|
||
await this.loadCart();
|
||
this.showToast('Quantity updated', 'success');
|
||
} else {
|
||
throw new Error('Failed to update quantity');
|
||
}
|
||
} catch (error) {
|
||
console.error('[SHOP] Update quantity error:', error);
|
||
this.showToast('Failed to update quantity', 'error');
|
||
} finally {
|
||
this.updating = false;
|
||
}
|
||
},
|
||
|
||
// Remove item from cart
|
||
async removeItem(productId) {
|
||
if (!confirm('Remove this item from your cart?')) {
|
||
return;
|
||
}
|
||
|
||
this.updating = true;
|
||
|
||
try {
|
||
console.log('[SHOP] Removing item:', productId);
|
||
const response = await fetch(
|
||
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
|
||
{
|
||
method: 'DELETE'
|
||
}
|
||
);
|
||
|
||
if (response.ok) {
|
||
await this.loadCart();
|
||
this.showToast('Item removed from cart', 'success');
|
||
} else {
|
||
throw new Error('Failed to remove item');
|
||
}
|
||
} catch (error) {
|
||
console.error('[SHOP] Remove item error:', error);
|
||
this.showToast('Failed to remove item', 'error');
|
||
} finally {
|
||
this.updating = false;
|
||
}
|
||
},
|
||
|
||
// Proceed to checkout
|
||
proceedToCheckout() {
|
||
// Check if customer is logged in
|
||
const token = localStorage.getItem('customer_token');
|
||
|
||
if (!token) {
|
||
// Redirect to login with return URL
|
||
window.location.href = '{{ base_url }}shop/account/login?return={{ base_url }}shop/checkout';
|
||
} else {
|
||
window.location.href = '{{ base_url }}shop/checkout';
|
||
}
|
||
}
|
||
};
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|