fix: refactor cart page to extend base template and fix styling

Issue:
- Cart page was standalone HTML without proper styling
- Referenced non-existent CSS files (/static/css/shared/base.css, /static/css/vendor/vendor.css)
- Missing header, footer, and navigation
- Not integrated with shop layout system

Changes:
- Extend shop/base.html for consistent styling and layout
- Remove hardcoded CSS references (now inherited from base)
- Add proper breadcrumbs with landing page navigation
- Integrate with shopLayoutData() for cart and toast functionality
- Use Tailwind classes consistent with other shop pages
- Add @error handler for broken product images
- Update all URLs to use {{ base_url }} for multi-tenant support
- Modernize layout with responsive grid system

The cart page now has proper styling, navigation, and integrates
seamlessly with the rest of the shop frontend.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-23 09:23:44 +01:00
parent 8c9190b054
commit 651e58ac6d

View File

@@ -1,489 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shopping Cart - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="shoppingCart()"
x-init="loadCart()"
data-vendor-id="{{ vendor.id }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<h1>🛒 Shopping Cart</h1>
</div>
<div class="header-right">
<a href="{{ base_url }}shop/" class="btn-secondary">← Continue Shopping</a>
</div>
</header>
{# app/templates/shop/cart.html #}
{% extends "shop/base.html" %}
<div class="container">
<!-- Loading State -->
<div x-show="loading && items.length === 0" class="loading">
<div class="loading-spinner-lg"></div>
<p>Loading your cart...</p>
</div>
{% block title %}Shopping Cart{% endblock %}
<!-- Empty Cart -->
<div x-show="!loading && items.length === 0" class="empty-state">
<div class="empty-state-icon">🛒</div>
<h3>Your cart is empty</h3>
<p>Add some products to get started!</p>
<a href="{{ base_url }}shop/products" class="btn-primary">Browse Products</a>
</div>
{# Alpine.js component #}
{% block alpine_data %}shoppingCart(){% endblock %}
<!-- Cart Items -->
<div x-show="items.length > 0" class="cart-content">
<!-- Cart Items List -->
<div class="cart-items">
<template x-for="item in items" :key="item.product_id">
<div class="cart-item-card">
<div class="item-image">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
:alt="item.name">
{% 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>
<div class="item-details">
<h3 class="item-name" x-text="item.name"></h3>
<p class="item-sku" x-text="'SKU: ' + item.sku"></p>
<p class="item-price">
{# 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>
</div>
<div class="item-quantity">
<label>Quantity:</label>
<div class="quantity-controls">
<button
{# Quantity Controls #}
<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="btn-quantity"
>
</button>
<input
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="quantity-input"
>
<button
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="btn-quantity"
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 class="item-total">
<label>Subtotal:</label>
<p class="item-total-price">
<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
</p>
</div>
<div class="item-actions">
<button
@click="removeItem(item.product_id)"
:disabled="updating"
class="btn-remove"
title="Remove from cart"
>
🗑️
</button>
</div>
</div>
</template>
</div>
<!-- Cart Summary -->
<div class="cart-summary">
<div class="summary-card">
<h3>Order Summary</h3>
<div class="summary-row">
<span>Subtotal (<span x-text="totalItems"></span> items):</span>
<span><span x-text="subtotal.toFixed(2)"></span></span>
</div>
<div class="summary-row">
<span>Shipping:</span>
<span x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
</div>
<div class="summary-row summary-total">
<span>Total:</span>
<span class="total-amount"><span x-text="total.toFixed(2)"></span></span>
</div>
<button
@click="proceedToCheckout()"
:disabled="updating || items.length === 0"
class="btn-primary btn-checkout"
>
Proceed to Checkout
</button>
<a href="{{ base_url }}shop/products" class="btn-outline">
Continue Shopping
</a>
</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>
function shoppingCart() {
return {
items: [],
loading: false,
updating: false,
vendorId: null,
sessionId: null,
document.addEventListener('alpine:init', () => {
Alpine.data('shoppingCart', () => ({
...shopLayoutData(),
// Computed properties
get totalItems() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
items: [],
loading: false,
updating: false,
get subtotal() {
return this.items.reduce((sum, item) =>
sum + (parseFloat(item.price) * item.quantity), 0
// 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...');
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 })
}
);
},
get shipping() {
// Free shipping over €50
return this.subtotal >= 50 ? 0 : 5.99;
},
get total() {
return this.subtotal + this.shipping;
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.sessionId = this.getOrCreateSessionId();
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
}
return sessionId;
},
// Load cart from API
async loadCart() {
this.loading = true;
try {
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}`
);
if (response.ok) {
const data = await response.json();
this.items = data.items || [];
}
} catch (error) {
console.error('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 {
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();
} else {
throw new Error('Failed to update quantity');
}
} catch (error) {
console.error('Update quantity error:', error);
alert('Failed to update quantity. Please try again.');
} finally {
this.updating = false;
}
},
// Remove item from cart
async removeItem(productId) {
if (!confirm('Remove this item from your cart?')) {
return;
}
this.updating = true;
try {
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
{
method: 'DELETE'
}
);
if (response.ok) {
await this.loadCart();
} else {
throw new Error('Failed to remove item');
}
} catch (error) {
console.error('Remove item error:', error);
alert('Failed to remove item. Please try again.');
} 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 = '/shop/account/login?return=/shop/checkout';
if (response.ok) {
await this.loadCart();
this.showToast('Quantity updated', 'success');
} else {
window.location.href = '/shop/checkout';
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>
<style>
/* Cart-specific styles */
.cart-content {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.cart-items {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.cart-item-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: grid;
grid-template-columns: 100px 1fr auto auto auto;
gap: var(--spacing-lg);
align-items: center;
box-shadow: var(--shadow-md);
}
.item-image img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: var(--radius-md);
}
.item-name {
font-size: var(--font-lg);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.item-sku {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: var(--spacing-xs);
}
.item-price {
font-size: var(--font-lg);
font-weight: 600;
color: var(--primary-color);
}
.quantity-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.btn-quantity {
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-lg);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.btn-quantity:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--primary-color);
}
.btn-quantity:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-input {
width: 60px;
text-align: center;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.item-total-price {
font-size: var(--font-xl);
font-weight: 700;
color: var(--text-primary);
}
.btn-remove {
width: 40px;
height: 40px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-lg);
transition: all var(--transition-base);
}
.btn-remove:hover:not(:disabled) {
background: var(--danger-color);
border-color: var(--danger-color);
color: white;
}
.cart-summary {
position: sticky;
top: var(--spacing-lg);
height: fit-content;
}
.summary-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
}
.summary-card h3 {
font-size: var(--font-xl);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--border-color);
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-md);
font-size: var(--font-base);
}
.summary-total {
font-size: var(--font-lg);
font-weight: 700;
padding-top: var(--spacing-md);
border-top: 2px solid var(--border-color);
margin-top: var(--spacing-md);
}
.total-amount {
color: var(--primary-color);
font-size: var(--font-2xl);
}
.btn-checkout {
width: 100%;
margin-top: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.btn-outline {
width: 100%;
display: block;
text-align: center;
padding: 10px 20px;
}
/* Responsive */
@media (max-width: 1024px) {
.cart-content {
grid-template-columns: 1fr;
}
.cart-summary {
position: static;
}
}
@media (max-width: 768px) {
.cart-item-card {
grid-template-columns: 80px 1fr;
gap: var(--spacing-md);
}
.item-image img {
width: 80px;
height: 80px;
}
.item-quantity,
.item-total {
grid-column: 2;
}
.item-actions {
grid-column: 2;
justify-self: end;
}
}
</style>
</body>
</html>
{% endblock %}