refactor: rename shop to storefront for consistency

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>
This commit is contained in:
2026-01-30 22:58:28 +01:00
parent 9decb9c29e
commit 7245f79f7b
62 changed files with 94 additions and 94 deletions

View File

@@ -0,0 +1,319 @@
{# 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 %}