refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
"""
|
||||
Cart module exceptions.
|
||||
|
||||
Module-specific exceptions for shopping cart operations.
|
||||
This module provides exception classes for cart operations including:
|
||||
- Cart item management
|
||||
- Quantity validation
|
||||
- Inventory checks
|
||||
"""
|
||||
|
||||
from app.exceptions.base import (
|
||||
@@ -11,6 +14,15 @@ from app.exceptions.base import (
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CartItemNotFoundException",
|
||||
"EmptyCartException",
|
||||
"CartValidationException",
|
||||
"InsufficientInventoryForCartException",
|
||||
"InvalidCartQuantityException",
|
||||
"ProductNotAvailableForCartException",
|
||||
]
|
||||
|
||||
|
||||
class CartItemNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a cart item is not found."""
|
||||
@@ -115,11 +127,3 @@ class ProductNotAvailableForCartException(BusinessLogicException):
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CartItemNotFoundException",
|
||||
"CartValidationException",
|
||||
"EmptyCartException",
|
||||
"InsufficientInventoryForCartException",
|
||||
"InvalidCartQuantityException",
|
||||
"ProductNotAvailableForCartException",
|
||||
]
|
||||
|
||||
2
app/modules/cart/routes/pages/__init__.py
Normal file
2
app/modules/cart/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/cart/routes/pages/__init__.py
|
||||
"""Cart module page routes."""
|
||||
46
app/modules/cart/routes/pages/storefront.py
Normal file
46
app/modules/cart/routes/pages/storefront.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/modules/cart/routes/pages/storefront.py
|
||||
"""
|
||||
Cart Storefront Page Routes (HTML rendering).
|
||||
|
||||
Storefront (customer shop) pages for shopping cart:
|
||||
- Cart page
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.modules.core.utils.page_context import get_storefront_context
|
||||
from app.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SHOPPING CART
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/cart", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_cart_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render shopping cart page.
|
||||
Shows cart items and allows quantity updates.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_cart_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"cart/storefront/cart.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
@@ -16,12 +16,12 @@ import logging
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
from app.modules.cart.exceptions import (
|
||||
CartItemNotFoundException,
|
||||
InsufficientInventoryForCartException,
|
||||
InvalidCartQuantityException,
|
||||
ProductNotFoundException,
|
||||
)
|
||||
from app.modules.catalog.exceptions import ProductNotFoundException
|
||||
from app.utils.money import cents_to_euros
|
||||
from app.modules.cart.models.cart import CartItem
|
||||
from app.modules.catalog.models import Product
|
||||
|
||||
319
app/modules/cart/templates/cart/storefront/cart.html
Normal file
319
app/modules/cart/templates/cart/storefront/cart.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user