Files
orion/app/modules/cart/templates/cart/storefront/cart.html
Samir Boulahtit a6e6d9be8e
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 46m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
refactor: rename shopLayoutData to storefrontLayoutData
Align Alpine.js base component naming with storefront terminology.
Updated across all storefront JS, templates, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:06:45 +01:00

322 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# app/templates/storefront/cart.html #}
{% extends "storefront/base.html" %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% 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 }}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 }}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/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/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="pendingRemoveProductId = item.product_id; showRemoveItemConfirm = true"
: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 }}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>
<!-- Remove Cart Item Confirm Modal -->
{{ confirm_modal('removeItemConfirm', 'Remove Item', 'Remove this item from your cart?', 'removeItem(pendingRemoveProductId)', 'showRemoveItemConfirm', 'Remove', 'Cancel', 'danger') }}
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shoppingCart', () => {
const baseData = storefrontLayoutData();
return {
...baseData,
items: [],
loading: false,
updating: false,
showRemoveItemConfirm: false,
pendingRemoveProductId: null,
// 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/storefront/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/storefront/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) {
this.updating = true;
try {
console.log('[SHOP] Removing item:', productId);
const response = await fetch(
`/api/v1/storefront/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 }}account/login?return={{ base_url }}checkout';
} else {
window.location.href = '{{ base_url }}checkout';
}
}
};
});
});
</script>
{% endblock %}