feat: add launch readiness features for OMS

- Implement VAT tax calculation on order creation based on EU country rates
- Add post-order hooks: customer stats update, cart clear, email confirmation
- Create shop order history page with pagination and status badges
- Create shop order detail page with order items and addresses
- Add order_confirmation email templates in 4 languages (en, fr, de, lb)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 19:37:17 +01:00
parent 8fd8168ff4
commit 098f008dfd
5 changed files with 953 additions and 9 deletions

View File

@@ -12,6 +12,7 @@ Customer Context: get_current_customer_api returns Customer directly
"""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
@@ -19,7 +20,10 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.services.cart_service import cart_service
from app.services.email_service import EmailService
from app.services.order_service import order_service
from app.utils.money import cents_to_euros
from models.database.customer import Customer
from models.schema.order import (
OrderCreate,
@@ -81,9 +85,53 @@ def place_order(
},
)
# TODO: Update customer stats
# TODO: Clear cart
# TODO: Send order confirmation email
# Update customer stats
customer.total_orders = (customer.total_orders or 0) + 1
customer.total_spent = (customer.total_spent or 0) + order.total_amount
customer.last_order_date = datetime.now(UTC)
db.flush()
logger.debug(
f"Updated customer stats: total_orders={customer.total_orders}, "
f"total_spent={customer.total_spent}"
)
# Clear cart (get session_id from request cookies or headers)
session_id = request.cookies.get("cart_session_id") or request.headers.get(
"X-Cart-Session-Id"
)
if session_id:
try:
cart_service.clear_cart(db, vendor.id, session_id)
logger.debug(f"Cleared cart for session {session_id}")
except Exception as e:
logger.warning(f"Failed to clear cart: {e}")
# Send order confirmation email
try:
email_service = EmailService(db)
email_service.send_template(
template_code="order_confirmation",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"order_number": order.order_number,
"order_total": f"{order.total_amount:.2f}",
"order_items_count": len(order.items),
"order_date": order.order_date.strftime("%d.%m.%Y")
if order.order_date
else "",
"shipping_address": f"{order.ship_address_line_1}, {order.ship_postal_code} {order.ship_city}",
},
vendor_id=vendor.id,
related_type="order",
related_id=order.id,
)
logger.info(f"Sent order confirmation email to {customer.email}")
except Exception as e:
logger.warning(f"Failed to send order confirmation email: {e}")
return OrderResponse.model_validate(order)

View File

@@ -18,6 +18,7 @@ See docs/architecture/money-handling.md for details.
import logging
import random
import string
from decimal import Decimal
from datetime import UTC, datetime
from typing import Any
@@ -37,6 +38,38 @@ from app.services.subscription_service import (
TierLimitExceededException,
)
from app.utils.money import Money, cents_to_euros, euros_to_cents
# EU VAT rates by country code (2024 standard rates)
# Duplicated from invoice_service to avoid circular imports
EU_VAT_RATES: dict[str, Decimal] = {
"AT": Decimal("20.00"), # Austria
"BE": Decimal("21.00"), # Belgium
"BG": Decimal("20.00"), # Bulgaria
"HR": Decimal("25.00"), # Croatia
"CY": Decimal("19.00"), # Cyprus
"CZ": Decimal("21.00"), # Czech Republic
"DK": Decimal("25.00"), # Denmark
"EE": Decimal("22.00"), # Estonia
"FI": Decimal("24.00"), # Finland
"FR": Decimal("20.00"), # France
"DE": Decimal("19.00"), # Germany
"GR": Decimal("24.00"), # Greece
"HU": Decimal("27.00"), # Hungary
"IE": Decimal("23.00"), # Ireland
"IT": Decimal("22.00"), # Italy
"LV": Decimal("21.00"), # Latvia
"LT": Decimal("21.00"), # Lithuania
"LU": Decimal("17.00"), # Luxembourg (standard)
"MT": Decimal("18.00"), # Malta
"NL": Decimal("21.00"), # Netherlands
"PL": Decimal("23.00"), # Poland
"PT": Decimal("23.00"), # Portugal
"RO": Decimal("19.00"), # Romania
"SK": Decimal("20.00"), # Slovakia
"SI": Decimal("22.00"), # Slovenia
"ES": Decimal("21.00"), # Spain
"SE": Decimal("25.00"), # Sweden
}
from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
@@ -87,6 +120,50 @@ class OrderService:
return order_number
# =========================================================================
# Tax Calculation
# =========================================================================
def _calculate_tax_for_order(
self, subtotal_cents: int, shipping_country_iso: str
) -> tuple[int, Decimal]:
"""
Calculate tax amount for an order based on shipping destination.
Uses EU VAT rates based on destination country. For B2C orders,
the tax is included in the product price, but we need to calculate
the tax component for reporting.
For Luxembourg vendors selling to EU countries:
- LU domestic: 17% VAT
- Other EU countries: destination country VAT rate
- Non-EU: 0% (VAT exempt)
Args:
subtotal_cents: Order subtotal in cents (before tax)
shipping_country_iso: ISO 2-letter country code
Returns:
tuple: (tax_amount_cents, vat_rate)
"""
country = shipping_country_iso.upper() if shipping_country_iso else "LU"
# Get VAT rate for destination country (0% if non-EU)
vat_rate = EU_VAT_RATES.get(country, Decimal("0.00"))
if vat_rate == Decimal("0.00"):
return 0, vat_rate
# Calculate tax: tax = subtotal * (rate / 100)
# Using Decimal for precision, then converting to cents
subtotal_decimal = Decimal(str(subtotal_cents))
tax_decimal = subtotal_decimal * (vat_rate / Decimal("100"))
# Round to nearest cent
tax_amount_cents = int(round(tax_decimal))
return tax_amount_cents, vat_rate
# =========================================================================
# Placeholder Product Management
# =========================================================================
@@ -369,7 +446,9 @@ class OrderService:
)
# Calculate totals in cents
tax_amount_cents = 0 # TODO: Implement tax calculation
tax_amount_cents, vat_rate = self._calculate_tax_for_order(
subtotal_cents, order_data.shipping_address.country_iso
)
shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50
discount_amount_cents = 0
total_amount_cents = Money.calculate_order_total(

View File

@@ -0,0 +1,322 @@
{# app/templates/shop/account/order-detail.html #}
{% extends "shop/base.html" %}
{% block title %}Order Details - {{ vendor.name }}{% endblock %}
{% block alpine_data %}shopOrderDetailPage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<a href="{{ base_url }}shop/account/orders" class="hover:text-primary">Orders</a>
</li>
<li class="flex items-center">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900 dark:text-white" x-text="order?.order_number || 'Order Details'"></span>
</li>
</ol>
</nav>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<div class="flex">
<svg class="h-6 w-6 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<a href="{{ base_url }}shop/account/orders"
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
Back to Orders
</a>
</div>
</div>
</div>
<!-- Order Content -->
<div x-show="!loading && !error && order" x-cloak>
<!-- Page Header -->
<div class="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" x-text="'Order ' + order.order_number"></h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Placed on <span x-text="formatDateTime(order.order_date || order.created_at)"></span>
</p>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
:class="getStatusClass(order.status)">
<span x-text="getStatusLabel(order.status)"></span>
</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content (Left Column - 2/3) -->
<div class="lg:col-span-2 space-y-6">
<!-- Order Items -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Order Items</h2>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in order.items" :key="item.id">
<div class="px-6 py-4 flex items-center gap-4">
<!-- Product Image Placeholder -->
<div class="flex-shrink-0 w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg class="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<!-- Product Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="item.product_name"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
SKU: <span x-text="item.product_sku || '-'"></span>
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Qty: <span x-text="item.quantity"></span>
&times;
<span x-text="formatPrice(item.unit_price)"></span>
</p>
</div>
<!-- Line Total -->
<div class="text-right">
<p class="text-sm font-semibold text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
</div>
</div>
</template>
</div>
</div>
<!-- Addresses -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Shipping Address -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Shipping Address
</h3>
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
<p class="font-medium" x-text="(order.ship_first_name || '') + ' ' + (order.ship_last_name || '')"></p>
<p x-show="order.ship_company" x-text="order.ship_company"></p>
<p x-text="order.ship_address_line_1"></p>
<p x-show="order.ship_address_line_2" x-text="order.ship_address_line_2"></p>
<p x-text="(order.ship_postal_code || '') + ' ' + (order.ship_city || '')"></p>
<p x-text="order.ship_country_iso"></p>
</div>
</div>
<!-- Billing Address -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Billing Address
</h3>
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
<p class="font-medium" x-text="(order.bill_first_name || '') + ' ' + (order.bill_last_name || '')"></p>
<p x-show="order.bill_company" x-text="order.bill_company"></p>
<p x-text="order.bill_address_line_1"></p>
<p x-show="order.bill_address_line_2" x-text="order.bill_address_line_2"></p>
<p x-text="(order.bill_postal_code || '') + ' ' + (order.bill_city || '')"></p>
<p x-text="order.bill_country_iso"></p>
</div>
</div>
</div>
<!-- Customer Notes -->
<div x-show="order.customer_notes" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
Order Notes
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300" x-text="order.customer_notes"></p>
</div>
</div>
<!-- Sidebar (Right Column - 1/3) -->
<div class="space-y-6">
<!-- Order Summary -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Summary</h3>
<dl class="space-y-3">
<div class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Subtotal</dt>
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.subtotal)"></dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Shipping</dt>
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.shipping_amount)"></dd>
</div>
<div x-show="order.tax_amount > 0" class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Tax</dt>
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.tax_amount)"></dd>
</div>
<div x-show="order.discount_amount > 0" class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Discount</dt>
<dd class="font-medium text-green-600 dark:text-green-400" x-text="'-' + formatPrice(order.discount_amount)"></dd>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 flex justify-between">
<dt class="text-base font-semibold text-gray-900 dark:text-white">Total</dt>
<dd class="text-base font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></dd>
</div>
</dl>
</div>
<!-- Shipping Info -->
<div x-show="order.shipping_method || order.tracking_number" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Shipping
</h3>
<div class="space-y-3 text-sm">
<div x-show="order.shipping_method">
<p class="text-gray-500 dark:text-gray-400">Method</p>
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_method"></p>
</div>
<div x-show="order.tracking_number">
<p class="text-gray-500 dark:text-gray-400">Tracking Number</p>
<p class="font-medium text-gray-900 dark:text-white" x-text="order.tracking_number"></p>
</div>
</div>
</div>
<!-- Need Help? -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Need Help?</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
If you have any questions about your order, please contact us.
</p>
<a href="{{ base_url }}shop/account/messages"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Contact Support
</a>
</div>
</div>
</div>
<!-- Back Button -->
<div class="mt-8">
<a href="{{ base_url }}shop/account/orders"
class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-primary">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Orders
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function shopOrderDetailPage() {
return {
...shopLayoutData(),
// State
order: null,
loading: true,
error: '',
orderId: {{ order_id }},
// Status mapping
statuses: {
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
},
async init() {
await this.loadOrder();
},
async loadOrder() {
this.loading = true;
this.error = '';
try {
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Order not found');
}
throw new Error('Failed to load order details');
}
this.order = await response.json();
} catch (err) {
console.error('Error loading order:', err);
this.error = err.message || 'Failed to load order';
} finally {
this.loading = false;
}
},
getStatusLabel(status) {
return this.statuses[status]?.label || status;
},
getStatusClass(status) {
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
}
</script>
{% endblock %}

View File

@@ -1,15 +1,223 @@
{# app/templates/shop/account/orders.html #}
{% extends "shop/base.html" %}
{% block title %}Order History{% endblock %}
{% block title %}Order History - {{ vendor.name }}{% endblock %}
{% block alpine_data %}shopOrdersPage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900 dark:text-white">Order History</span>
</li>
</ol>
</nav>
{# TODO: Implement order history #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Order history coming soon...</p>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Order History</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">View and track your orders</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && !error && orders.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No orders yet</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">Start shopping to see your orders here.</p>
<a href="{{ base_url }}shop/products"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark transition-colors"
style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
<!-- Orders List -->
<div x-show="!loading && !error && orders.length > 0" class="space-y-4">
<template x-for="order in orders" :key="order.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Order Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-6">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Order Number</p>
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="order.order_number"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Date</p>
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatDate(order.order_date || order.created_at)"></p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
<p class="text-sm font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></p>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Status Badge -->
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getStatusClass(order.status)">
<span x-text="getStatusLabel(order.status)"></span>
</span>
<!-- View Details Button -->
<a :href="'{{ base_url }}shop/account/orders/' + order.id"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
View Details
<svg class="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
<!-- Order Items Preview -->
<div class="px-6 py-4">
<template x-for="item in order.items.slice(0, 3)" :key="item.id">
<div class="flex items-center py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.product_name"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Qty: <span x-text="item.quantity"></span> &times;
<span x-text="formatPrice(item.unit_price)"></span>
</p>
</div>
<p class="ml-4 text-sm font-medium text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
</div>
</template>
<p x-show="order.items.length > 3"
class="text-sm text-gray-500 dark:text-gray-400 mt-2"
x-text="'+ ' + (order.items.length - 3) + ' more item(s)'"></p>
</div>
</div>
</template>
<!-- Pagination -->
<div x-show="totalPages > 1" class="flex justify-center mt-8">
<nav class="flex items-center space-x-2">
<button @click="loadOrders(currentPage - 1)"
:disabled="currentPage === 1"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed">
Previous
</button>
<span class="text-sm text-gray-700 dark:text-gray-300">
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
</span>
<button @click="loadOrders(currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed">
Next
</button>
</nav>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function shopOrdersPage() {
return {
...shopLayoutData(),
// State
orders: [],
loading: true,
error: '',
currentPage: 1,
totalPages: 1,
perPage: 10,
// Status mapping
statuses: {
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
},
async init() {
await this.loadOrders(1);
},
async loadOrders(page = 1) {
this.loading = true;
this.error = '';
try {
const skip = (page - 1) * this.perPage;
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`);
if (!response.ok) {
throw new Error('Failed to load orders');
}
const data = await response.json();
this.orders = data.orders || [];
this.currentPage = page;
this.totalPages = Math.ceil((data.total || 0) / this.perPage);
} catch (err) {
console.error('Error loading orders:', err);
this.error = err.message || 'Failed to load orders';
} finally {
this.loading = false;
}
},
getStatusLabel(status) {
return this.statuses[status]?.label || status;
},
getStatusClass(status) {
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
}
</script>
{% endblock %}

View File

@@ -366,6 +366,293 @@ Fir unzefänken:
Mat beschte Gréiss,
D'Wizamart Team
""",
},
# -------------------------------------------------------------------------
# ORDER CONFIRMATION
# -------------------------------------------------------------------------
{
"code": "order_confirmation",
"language": "en",
"name": "Order Confirmation",
"description": "Sent to customers after placing an order",
"category": EmailCategory.ORDERS.value,
"variables": json.dumps([
"customer_name", "order_number", "order_total",
"order_items_count", "order_date", "shipping_address"
]),
"subject": "Order Confirmation - {{ order_number }}",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Order Confirmed!</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>Thank you for your order! We've received your order and it's being processed.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="margin-top: 0; color: #10b981;">Order Details</h3>
<p style="margin: 5px 0;"><strong>Order Number:</strong> {{ order_number }}</p>
<p style="margin: 5px 0;"><strong>Date:</strong> {{ order_date }}</p>
<p style="margin: 5px 0;"><strong>Items:</strong> {{ order_items_count }}</p>
<p style="margin: 5px 0;"><strong>Total:</strong> {{ order_total }}</p>
</div>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #374151;">Shipping Address</h3>
<p style="margin: 5px 0;">{{ shipping_address }}</p>
</div>
<p>You will receive another email when your order ships.</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
If you have any questions about your order, please contact us.
</p>
<p>Thank you for shopping with us!<br><strong>The Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</body>
</html>""",
"body_text": """Order Confirmed!
Hi {{ customer_name }},
Thank you for your order! We've received your order and it's being processed.
Order Details:
- Order Number: {{ order_number }}
- Date: {{ order_date }}
- Items: {{ order_items_count }}
- Total: {{ order_total }}
Shipping Address:
{{ shipping_address }}
You will receive another email when your order ships.
Thank you for shopping with us!
""",
},
{
"code": "order_confirmation",
"language": "fr",
"name": "Confirmation de commande",
"description": "Envoyé aux clients après avoir passé une commande",
"category": EmailCategory.ORDERS.value,
"variables": json.dumps([
"customer_name", "order_number", "order_total",
"order_items_count", "order_date", "shipping_address"
]),
"subject": "Confirmation de commande - {{ order_number }}",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Commande confirmée !</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Merci pour votre commande ! Nous l'avons bien reçue et elle est en cours de traitement.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="margin-top: 0; color: #10b981;">Détails de la commande</h3>
<p style="margin: 5px 0;"><strong>Numéro de commande :</strong> {{ order_number }}</p>
<p style="margin: 5px 0;"><strong>Date :</strong> {{ order_date }}</p>
<p style="margin: 5px 0;"><strong>Articles :</strong> {{ order_items_count }}</p>
<p style="margin: 5px 0;"><strong>Total :</strong> {{ order_total }}</p>
</div>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #374151;">Adresse de livraison</h3>
<p style="margin: 5px 0;">{{ shipping_address }}</p>
</div>
<p>Vous recevrez un autre email lors de l'expédition de votre commande.</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
Si vous avez des questions concernant votre commande, n'hésitez pas à nous contacter.
</p>
<p>Merci pour votre achat !<br><strong>L'équipe</strong></p>
</div>
</body>
</html>""",
"body_text": """Commande confirmée !
Bonjour {{ customer_name }},
Merci pour votre commande ! Nous l'avons bien reçue et elle est en cours de traitement.
Détails de la commande :
- Numéro de commande : {{ order_number }}
- Date : {{ order_date }}
- Articles : {{ order_items_count }}
- Total : {{ order_total }}
Adresse de livraison :
{{ shipping_address }}
Vous recevrez un autre email lors de l'expédition de votre commande.
Merci pour votre achat !
""",
},
{
"code": "order_confirmation",
"language": "de",
"name": "Bestellbestätigung",
"description": "An Kunden nach einer Bestellung gesendet",
"category": EmailCategory.ORDERS.value,
"variables": json.dumps([
"customer_name", "order_number", "order_total",
"order_items_count", "order_date", "shipping_address"
]),
"subject": "Bestellbestätigung - {{ order_number }}",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Bestellung bestätigt!</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Vielen Dank für Ihre Bestellung! Wir haben Ihre Bestellung erhalten und sie wird bearbeitet.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="margin-top: 0; color: #10b981;">Bestelldetails</h3>
<p style="margin: 5px 0;"><strong>Bestellnummer:</strong> {{ order_number }}</p>
<p style="margin: 5px 0;"><strong>Datum:</strong> {{ order_date }}</p>
<p style="margin: 5px 0;"><strong>Artikel:</strong> {{ order_items_count }}</p>
<p style="margin: 5px 0;"><strong>Summe:</strong> {{ order_total }}</p>
</div>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #374151;">Lieferadresse</h3>
<p style="margin: 5px 0;">{{ shipping_address }}</p>
</div>
<p>Sie erhalten eine weitere E-Mail, sobald Ihre Bestellung versandt wird.</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
Bei Fragen zu Ihrer Bestellung kontaktieren Sie uns bitte.
</p>
<p>Vielen Dank für Ihren Einkauf!<br><strong>Das Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Bestellung bestätigt!
Hallo {{ customer_name }},
Vielen Dank für Ihre Bestellung! Wir haben Ihre Bestellung erhalten und sie wird bearbeitet.
Bestelldetails:
- Bestellnummer: {{ order_number }}
- Datum: {{ order_date }}
- Artikel: {{ order_items_count }}
- Summe: {{ order_total }}
Lieferadresse:
{{ shipping_address }}
Sie erhalten eine weitere E-Mail, sobald Ihre Bestellung versandt wird.
Vielen Dank für Ihren Einkauf!
""",
},
{
"code": "order_confirmation",
"language": "lb",
"name": "Bestellung Konfirmatioun",
"description": "Un Clienten no enger Bestellung geschéckt",
"category": EmailCategory.ORDERS.value,
"variables": json.dumps([
"customer_name", "order_number", "order_total",
"order_items_count", "order_date", "shipping_address"
]),
"subject": "Bestellung Konfirmatioun - {{ order_number }}",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Bestellung confirméiert!</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Merci fir Är Bestellung! Mir hunn Är Bestellung kritt an si gëtt beaarbecht.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="margin-top: 0; color: #10b981;">Bestelldetailer</h3>
<p style="margin: 5px 0;"><strong>Bestellnummer:</strong> {{ order_number }}</p>
<p style="margin: 5px 0;"><strong>Datum:</strong> {{ order_date }}</p>
<p style="margin: 5px 0;"><strong>Artikelen:</strong> {{ order_items_count }}</p>
<p style="margin: 5px 0;"><strong>Total:</strong> {{ order_total }}</p>
</div>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #374151;">Liwweradress</h3>
<p style="margin: 5px 0;">{{ shipping_address }}</p>
</div>
<p>Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt.</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
Wann Dir Froen iwwer Är Bestellung hutt, kontaktéiert eis w.e.g.
</p>
<p>Merci fir Ären Akaf!<br><strong>D'Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Bestellung confirméiert!
Moien {{ customer_name }},
Merci fir Är Bestellung! Mir hunn Är Bestellung kritt an si gëtt beaarbecht.
Bestelldetailer:
- Bestellnummer: {{ order_number }}
- Datum: {{ order_date }}
- Artikelen: {{ order_items_count }}
- Total: {{ order_total }}
Liwweradress:
{{ shipping_address }}
Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt.
Merci fir Ären Akaf!
""",
},
]