feat: add customer profile, VAT alignment, and fix shop auth
Customer Profile: - Add profile API (GET/PUT /api/v1/shop/profile) - Add password change endpoint (PUT /api/v1/shop/profile/password) - Implement full profile page with preferences and password sections - Add CustomerPasswordChange schema Shop Authentication Fixes: - Add Authorization header to all shop account API calls - Fix orders, order-detail, messages pages authentication - Add proper redirect to login on 401 responses - Fix toast message showing noqa comment in shop-layout.js VAT Calculation: - Add shared VAT utility (app/utils/vat.py) - Add VAT fields to Order model (vat_regime, vat_rate, etc.) - Align order VAT calculation with invoice settings - Add migration for VAT fields on orders Validation Framework: - Fix base_validator.py with missing methods - Add validate_file, output_results, get_exit_code methods - Fix validate_all.py import issues Documentation: - Add launch-readiness.md tracking OMS status - Update to 95% feature complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ Authentication:
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import shop routers
|
||||
from . import addresses, auth, carts, content_pages, messages, orders, products
|
||||
from . import addresses, auth, carts, content_pages, messages, orders, products, profile
|
||||
|
||||
# Create shop router
|
||||
router = APIRouter()
|
||||
@@ -48,6 +48,9 @@ router.include_router(orders.router, tags=["shop-orders"])
|
||||
# Messages (authenticated)
|
||||
router.include_router(messages.router, tags=["shop-messages"])
|
||||
|
||||
# Profile (authenticated)
|
||||
router.include_router(profile.router, tags=["shop-profile"])
|
||||
|
||||
# Content pages (public)
|
||||
router.include_router(
|
||||
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]
|
||||
|
||||
@@ -13,15 +13,19 @@ Customer Context: get_current_customer_api returns Customer directly
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
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.exceptions.invoice import InvoicePDFNotFoundException
|
||||
from app.services.cart_service import cart_service
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.invoice_service import invoice_service
|
||||
from app.services.order_service import order_service
|
||||
from app.utils.money import cents_to_euros
|
||||
from models.database.customer import Customer
|
||||
@@ -226,3 +230,101 @@ def get_order_details(
|
||||
raise OrderNotFoundException(str(order_id))
|
||||
|
||||
return OrderDetailResponse.model_validate(order)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}/invoice")
|
||||
def download_order_invoice(
|
||||
request: Request,
|
||||
order_id: int = Path(..., description="Order ID", gt=0),
|
||||
customer: Customer = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Download invoice PDF for a customer's order.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Customer can only download invoices for their own orders.
|
||||
Invoice is auto-generated if it doesn't exist.
|
||||
|
||||
Path Parameters:
|
||||
- order_id: ID of the order to get invoice for
|
||||
"""
|
||||
from app.exceptions import OrderNotFoundException
|
||||
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] download_order_invoice: order {order_id}",
|
||||
extra={
|
||||
"vendor_id": vendor.id,
|
||||
"vendor_code": vendor.subdomain,
|
||||
"customer_id": customer.id,
|
||||
"order_id": order_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Get order
|
||||
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
|
||||
|
||||
# Verify order belongs to customer
|
||||
if order.customer_id != customer.id:
|
||||
raise OrderNotFoundException(str(order_id))
|
||||
|
||||
# Only allow invoice download for orders that are at least processing
|
||||
allowed_statuses = ["processing", "partially_shipped", "shipped", "delivered", "completed"]
|
||||
if order.status not in allowed_statuses:
|
||||
from app.exceptions import ValidationException
|
||||
raise ValidationException("Invoice not available for pending orders")
|
||||
|
||||
# Check if invoice exists for this order (via service layer)
|
||||
invoice = invoice_service.get_invoice_by_order_id(
|
||||
db=db, vendor_id=vendor.id, order_id=order_id
|
||||
)
|
||||
|
||||
# Create invoice if it doesn't exist
|
||||
if not invoice:
|
||||
logger.info(f"Creating invoice for order {order_id} (customer download)")
|
||||
invoice = invoice_service.create_invoice_from_order(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
order_id=order_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Get or generate PDF
|
||||
pdf_path = invoice_service.get_pdf_path(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
invoice_id=invoice.id,
|
||||
)
|
||||
|
||||
if not pdf_path:
|
||||
# Generate PDF
|
||||
pdf_path = invoice_service.generate_pdf(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
invoice_id=invoice.id,
|
||||
)
|
||||
|
||||
# Verify file exists
|
||||
if not FilePath(pdf_path).exists():
|
||||
raise InvoicePDFNotFoundException(invoice.id)
|
||||
|
||||
filename = f"invoice-{invoice.invoice_number}.pdf"
|
||||
|
||||
logger.info(
|
||||
f"Customer {customer.id} downloading invoice {invoice.invoice_number} for order {order.order_number}"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename=filename,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
},
|
||||
)
|
||||
|
||||
161
app/api/v1/shop/profile.py
Normal file
161
app/api/v1/shop/profile.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# app/api/v1/shop/profile.py
|
||||
"""
|
||||
Shop Profile API (Customer authenticated)
|
||||
|
||||
Endpoints for managing customer profile in shop frontend.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_api
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.exceptions import ValidationException
|
||||
from models.database.customer import Customer
|
||||
from models.schema.customer import (
|
||||
CustomerPasswordChange,
|
||||
CustomerResponse,
|
||||
CustomerUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=CustomerResponse)
|
||||
def get_profile(
|
||||
customer: Customer = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current customer profile.
|
||||
|
||||
Returns the authenticated customer's profile information.
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SHOP_API] get_profile for customer {customer.id}",
|
||||
extra={
|
||||
"customer_id": customer.id,
|
||||
"email": customer.email,
|
||||
},
|
||||
)
|
||||
|
||||
return CustomerResponse.model_validate(customer)
|
||||
|
||||
|
||||
@router.put("/profile", response_model=CustomerResponse)
|
||||
def update_profile(
|
||||
update_data: CustomerUpdate,
|
||||
customer: Customer = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update current customer profile.
|
||||
|
||||
Allows updating profile fields like name, phone, marketing consent, etc.
|
||||
Email changes require the new email to be unique within the vendor.
|
||||
|
||||
Request Body:
|
||||
- email: New email address (optional)
|
||||
- first_name: First name (optional)
|
||||
- last_name: Last name (optional)
|
||||
- phone: Phone number (optional)
|
||||
- marketing_consent: Marketing consent (optional)
|
||||
- preferred_language: Preferred language (optional)
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SHOP_API] update_profile for customer {customer.id}",
|
||||
extra={
|
||||
"customer_id": customer.id,
|
||||
"email": customer.email,
|
||||
"update_fields": [k for k, v in update_data.model_dump().items() if v is not None],
|
||||
},
|
||||
)
|
||||
|
||||
# If email is being changed, check uniqueness within vendor
|
||||
if update_data.email and update_data.email != customer.email:
|
||||
existing = (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
Customer.vendor_id == customer.vendor_id,
|
||||
Customer.email == update_data.email,
|
||||
Customer.id != customer.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValidationException("Email already in use")
|
||||
|
||||
# Update only provided fields
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_dict.items():
|
||||
if value is not None:
|
||||
setattr(customer, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
logger.info(
|
||||
f"Customer {customer.id} updated profile",
|
||||
extra={
|
||||
"customer_id": customer.id,
|
||||
"updated_fields": list(update_dict.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
return CustomerResponse.model_validate(customer)
|
||||
|
||||
|
||||
@router.put("/profile/password", response_model=dict)
|
||||
def change_password(
|
||||
password_data: CustomerPasswordChange,
|
||||
customer: Customer = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Change customer password.
|
||||
|
||||
Requires current password verification and matching new password confirmation.
|
||||
|
||||
Request Body:
|
||||
- current_password: Current password
|
||||
- new_password: New password (min 8 chars, must contain letter and digit)
|
||||
- confirm_password: Confirmation of new password
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SHOP_API] change_password for customer {customer.id}",
|
||||
extra={
|
||||
"customer_id": customer.id,
|
||||
"email": customer.email,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(password_data.current_password, customer.hashed_password):
|
||||
raise ValidationException("Current password is incorrect")
|
||||
|
||||
# Verify passwords match
|
||||
if password_data.new_password != password_data.confirm_password:
|
||||
raise ValidationException("New passwords do not match")
|
||||
|
||||
# Check new password is different
|
||||
if password_data.new_password == password_data.current_password:
|
||||
raise ValidationException("New password must be different from current password")
|
||||
|
||||
# Update password
|
||||
customer.hashed_password = get_password_hash(password_data.new_password)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Customer {customer.id} changed password",
|
||||
extra={
|
||||
"customer_id": customer.id,
|
||||
"email": customer.email,
|
||||
},
|
||||
)
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
@@ -477,6 +477,21 @@ class InvoiceService:
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_invoice_by_order_id(
|
||||
self, db: Session, vendor_id: int, order_id: int
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by order ID."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.order_id == order_id,
|
||||
Invoice.vendor_id == vendor_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_invoices(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -38,38 +38,11 @@ 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 app.utils.vat import (
|
||||
VATResult,
|
||||
calculate_vat_amount,
|
||||
determine_vat_regime,
|
||||
)
|
||||
from models.database.customer import Customer
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.marketplace_product_translation import MarketplaceProductTranslation
|
||||
@@ -125,44 +98,56 @@ class OrderService:
|
||||
# =========================================================================
|
||||
|
||||
def _calculate_tax_for_order(
|
||||
self, subtotal_cents: int, shipping_country_iso: str
|
||||
) -> tuple[int, Decimal]:
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
subtotal_cents: int,
|
||||
billing_country_iso: str,
|
||||
buyer_vat_number: str | None = None,
|
||||
) -> VATResult:
|
||||
"""
|
||||
Calculate tax amount for an order based on shipping destination.
|
||||
Calculate tax amount for an order based on billing 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.
|
||||
Uses the shared VAT utility to determine the correct VAT regime
|
||||
and rate, consistent with invoice VAT calculation.
|
||||
|
||||
For Luxembourg vendors selling to EU countries:
|
||||
- LU domestic: 17% VAT
|
||||
- Other EU countries: destination country VAT rate
|
||||
- Non-EU: 0% (VAT exempt)
|
||||
VAT Logic:
|
||||
- Same country as seller: domestic VAT
|
||||
- B2B with valid VAT number: reverse charge (0%)
|
||||
- Cross-border + OSS registered: destination country VAT
|
||||
- Cross-border + no OSS: origin country VAT
|
||||
- Non-EU: VAT exempt (0%)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (to get invoice settings)
|
||||
subtotal_cents: Order subtotal in cents (before tax)
|
||||
shipping_country_iso: ISO 2-letter country code
|
||||
billing_country_iso: ISO 2-letter country code
|
||||
buyer_vat_number: Buyer's VAT number for B2B detection
|
||||
|
||||
Returns:
|
||||
tuple: (tax_amount_cents, vat_rate)
|
||||
VATResult with regime, rate, destination country, and label
|
||||
"""
|
||||
country = shipping_country_iso.upper() if shipping_country_iso else "LU"
|
||||
from models.database.invoice import VendorInvoiceSettings
|
||||
|
||||
# Get VAT rate for destination country (0% if non-EU)
|
||||
vat_rate = EU_VAT_RATES.get(country, Decimal("0.00"))
|
||||
# Get vendor invoice settings for seller country and OSS status
|
||||
settings = (
|
||||
db.query(VendorInvoiceSettings)
|
||||
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if vat_rate == Decimal("0.00"):
|
||||
return 0, vat_rate
|
||||
# Default to Luxembourg if no settings exist
|
||||
seller_country = settings.company_country if settings else "LU"
|
||||
seller_oss_registered = settings.is_oss_registered if settings else False
|
||||
|
||||
# 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
|
||||
# Determine VAT regime using shared utility
|
||||
return determine_vat_regime(
|
||||
seller_country=seller_country,
|
||||
buyer_country=billing_country_iso or "LU",
|
||||
buyer_vat_number=buyer_vat_number,
|
||||
seller_oss_registered=seller_oss_registered,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Placeholder Product Management
|
||||
@@ -445,19 +430,26 @@ class OrderService:
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate totals in cents
|
||||
tax_amount_cents, vat_rate = self._calculate_tax_for_order(
|
||||
subtotal_cents, order_data.shipping_address.country_iso
|
||||
# Use billing address or shipping address for VAT
|
||||
billing = order_data.billing_address or order_data.shipping_address
|
||||
|
||||
# Calculate VAT using vendor settings (OSS, B2B handling)
|
||||
vat_result = self._calculate_tax_for_order(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
subtotal_cents=subtotal_cents,
|
||||
billing_country_iso=billing.country_iso,
|
||||
buyer_vat_number=getattr(billing, 'vat_number', None),
|
||||
)
|
||||
|
||||
# Calculate amounts in cents
|
||||
tax_amount_cents = calculate_vat_amount(subtotal_cents, vat_result.rate)
|
||||
shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50
|
||||
discount_amount_cents = 0
|
||||
total_amount_cents = Money.calculate_order_total(
|
||||
subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents
|
||||
)
|
||||
|
||||
# Use billing address or shipping address
|
||||
billing = order_data.billing_address or order_data.shipping_address
|
||||
|
||||
# Generate order number
|
||||
order_number = self._generate_order_number(db, vendor_id)
|
||||
|
||||
@@ -475,6 +467,11 @@ class OrderService:
|
||||
discount_amount_cents=discount_amount_cents,
|
||||
total_amount_cents=total_amount_cents,
|
||||
currency="EUR",
|
||||
# VAT information
|
||||
vat_regime=vat_result.regime.value,
|
||||
vat_rate=vat_result.rate,
|
||||
vat_rate_label=vat_result.label,
|
||||
vat_destination_country=vat_result.destination_country,
|
||||
# Customer snapshot
|
||||
customer_first_name=order_data.customer.first_name,
|
||||
customer_last_name=order_data.customer.last_name,
|
||||
@@ -987,6 +984,37 @@ class OrderService:
|
||||
|
||||
return orders, total
|
||||
|
||||
def get_customer_orders(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[Order], int]:
|
||||
"""
|
||||
Get orders for a specific customer.
|
||||
|
||||
Used by shop frontend for customer order history.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
|
||||
Returns:
|
||||
Tuple of (orders, total_count)
|
||||
"""
|
||||
return self.get_vendor_orders(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
|
||||
def get_order_stats(self, db: Session, vendor_id: int) -> dict[str, int]:
|
||||
"""
|
||||
Get order counts by status for a vendor.
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div x-show="showAddressModal"
|
||||
@click.stop
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
@@ -286,6 +287,7 @@
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div x-show="showDeleteModal"
|
||||
@click.stop
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
|
||||
@@ -309,6 +309,12 @@ function shopMessages() {
|
||||
|
||||
async loadConversations() {
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.currentPage - 1) * this.limit,
|
||||
limit: this.limit,
|
||||
@@ -317,8 +323,20 @@ function shopMessages() {
|
||||
params.append('status', this.statusFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to load conversations');
|
||||
const response = await fetch(`/api/v1/shop/messages?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load conversations');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.conversations = data.conversations;
|
||||
@@ -334,7 +352,17 @@ function shopMessages() {
|
||||
|
||||
async selectConversation(conversationId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/messages/${conversationId}`);
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to load conversation');
|
||||
|
||||
this.selectedConversation = await response.json();
|
||||
@@ -360,7 +388,14 @@ function shopMessages() {
|
||||
if (!this.selectedConversation) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`);
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -397,6 +432,12 @@ function shopMessages() {
|
||||
this.sending = true;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', this.replyContent);
|
||||
for (const file of this.attachments) {
|
||||
@@ -405,6 +446,9 @@ function shopMessages() {
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,97 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Order Tracking Timeline -->
|
||||
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">Order Progress</h2>
|
||||
<div class="relative">
|
||||
<!-- Timeline Line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
<!-- Timeline Steps -->
|
||||
<div class="space-y-6">
|
||||
<!-- Pending -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('pending')">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Order Placed</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDateTime(order.order_date || order.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('processing')">
|
||||
<svg x-show="isStepComplete('processing')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg x-show="!isStepComplete('processing')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('processing') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Processing</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.confirmed_at" x-text="formatDateTime(order.confirmed_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipped -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('shipped')">
|
||||
<svg x-show="isStepComplete('shipped')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg x-show="!isStepComplete('shipped')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM15 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
|
||||
<path d="M3 4a1 1 0 00-1 1v10a1 1 0 001 1h1.05a2.5 2.5 0 014.9 0H10a1 1 0 001-1V5a1 1 0 00-1-1H3zM14 7a1 1 0 00-1 1v6.05A2.5 2.5 0 0115.95 16H17a1 1 0 001-1v-5a1 1 0 00-.293-.707l-2-2A1 1 0 0015 7h-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('shipped') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Shipped</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.shipped_at" x-text="formatDateTime(order.shipped_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivered -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('delivered')">
|
||||
<svg x-show="isStepComplete('delivered')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg x-show="!isStepComplete('delivered')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('delivered') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Delivered</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.delivered_at" x-text="formatDateTime(order.delivered_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled/Refunded Notice -->
|
||||
<div x-show="order.status === 'cancelled' || order.status === 'refunded'"
|
||||
class="mt-6 p-4 rounded-lg"
|
||||
:class="order.status === 'cancelled' ? 'bg-red-50 dark:bg-red-900/20' : 'bg-gray-50 dark:bg-gray-700'">
|
||||
<p class="text-sm font-medium"
|
||||
:class="order.status === 'cancelled' ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'"
|
||||
x-text="order.status === 'cancelled' ? 'This order was cancelled' : 'This order was refunded'"></p>
|
||||
<p class="text-sm mt-1"
|
||||
:class="order.status === 'cancelled' ? 'text-red-600 dark:text-red-300' : 'text-gray-600 dark:text-gray-400'"
|
||||
x-show="order.cancelled_at"
|
||||
x-text="'on ' + formatDateTime(order.cancelled_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -171,10 +262,21 @@
|
||||
<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>
|
||||
<!-- VAT Breakdown -->
|
||||
<div x-show="order.tax_amount > 0 || order.vat_rate_label" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">
|
||||
<span x-text="order.vat_rate_label || 'Tax'"></span>
|
||||
<span x-show="order.vat_rate" class="text-xs ml-1">(<span x-text="order.vat_rate"></span>%)</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.tax_amount)"></dd>
|
||||
</div>
|
||||
<!-- VAT Regime Info (for special cases) -->
|
||||
<div x-show="order.vat_regime === 'reverse_charge'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Reverse Charge applies (B2B transaction)
|
||||
</div>
|
||||
<div x-show="order.vat_regime === 'exempt'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Exempt (Non-EU destination)
|
||||
</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>
|
||||
@@ -186,6 +288,31 @@
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Download -->
|
||||
<div x-show="canDownloadInvoice()" 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>
|
||||
Invoice
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Download your invoice for this order.
|
||||
</p>
|
||||
<button @click="downloadInvoice()"
|
||||
:disabled="downloadingInvoice"
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm 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 transition-colors">
|
||||
<svg x-show="!downloadingInvoice" 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<svg x-show="downloadingInvoice" class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="downloadingInvoice ? 'Generating...' : 'Download Invoice'"></span>
|
||||
</button>
|
||||
</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">
|
||||
@@ -199,10 +326,27 @@
|
||||
<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.shipping_carrier">
|
||||
<p class="text-gray-500 dark:text-gray-400">Carrier</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_carrier"></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>
|
||||
<!-- Track Package Button -->
|
||||
<a x-show="order.tracking_url"
|
||||
:href="order.tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 w-full inline-flex justify-center 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="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>
|
||||
Track Package
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,6 +393,7 @@ function shopOrderDetailPage() {
|
||||
loading: true,
|
||||
error: '',
|
||||
orderId: {{ order_id }},
|
||||
downloadingInvoice: false,
|
||||
|
||||
// Status mapping
|
||||
statuses: {
|
||||
@@ -262,6 +407,9 @@ function shopOrderDetailPage() {
|
||||
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
},
|
||||
|
||||
// Timeline step order for progress tracking
|
||||
timelineSteps: ['pending', 'processing', 'shipped', 'delivered'],
|
||||
|
||||
async init() {
|
||||
await this.loadOrder();
|
||||
},
|
||||
@@ -271,9 +419,25 @@ function shopOrderDetailPage() {
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`);
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Order not found');
|
||||
}
|
||||
@@ -315,6 +479,131 @@ function shopOrderDetailPage() {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// ===== Timeline Functions =====
|
||||
|
||||
/**
|
||||
* Get the current step index in the order flow
|
||||
*/
|
||||
getCurrentStepIndex() {
|
||||
if (!this.order) return 0;
|
||||
const status = this.order.status;
|
||||
|
||||
// Handle special statuses
|
||||
if (status === 'cancelled' || status === 'refunded') {
|
||||
return -1; // Special case
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return 4; // All steps complete
|
||||
}
|
||||
if (status === 'partially_shipped') {
|
||||
return 2; // Between processing and shipped
|
||||
}
|
||||
|
||||
return this.timelineSteps.indexOf(status) + 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a timeline step is complete
|
||||
*/
|
||||
isStepComplete(step) {
|
||||
if (!this.order) return false;
|
||||
|
||||
const currentIndex = this.getCurrentStepIndex();
|
||||
const stepIndex = this.timelineSteps.indexOf(step) + 1;
|
||||
|
||||
return currentIndex >= stepIndex;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get CSS classes for a timeline step
|
||||
*/
|
||||
getTimelineStepClass(step) {
|
||||
if (this.isStepComplete(step)) {
|
||||
// Completed step - green
|
||||
return 'bg-green-500 text-white';
|
||||
} else if (this.order && this.timelineSteps.indexOf(this.order.status) === this.timelineSteps.indexOf(step)) {
|
||||
// Current step - primary color with pulse
|
||||
return 'bg-blue-500 text-white animate-pulse';
|
||||
} else {
|
||||
// Future step - gray
|
||||
return 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Invoice Functions =====
|
||||
|
||||
/**
|
||||
* Check if invoice can be downloaded (order must be at least processing)
|
||||
*/
|
||||
canDownloadInvoice() {
|
||||
if (!this.order) return false;
|
||||
const invoiceStatuses = ['processing', 'partially_shipped', 'shipped', 'delivered', 'completed'];
|
||||
return invoiceStatuses.includes(this.order.status);
|
||||
},
|
||||
|
||||
/**
|
||||
* Download invoice PDF for this order
|
||||
*/
|
||||
async downloadInvoice() {
|
||||
if (this.downloadingInvoice) return;
|
||||
|
||||
this.downloadingInvoice = true;
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}/invoice`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Invoice not yet available. Please try again later.');
|
||||
}
|
||||
throw new Error('Failed to download invoice');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header if available
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `invoice-${this.order.order_number}.pdf`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
filename = match[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Download the blob
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error downloading invoice:', err);
|
||||
alert(err.message || 'Failed to download invoice');
|
||||
} finally {
|
||||
this.downloadingInvoice = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +173,26 @@ function shopOrdersPage() {
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * this.perPage;
|
||||
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`);
|
||||
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load orders');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,545 @@
|
||||
{# app/templates/shop/account/profile.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}My Profile{% endblock %}
|
||||
{% block title %}My Profile - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopProfilePage(){% 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">My Profile</h1>
|
||||
<div class="max-w-3xl 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>
|
||||
<span class="text-gray-900 dark:text-white">Profile</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# TODO: Implement profile management #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Profile</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account information and preferences</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>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<p class="ml-3 text-sm text-green-700 dark:text-green-300" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading" class="space-y-8">
|
||||
<!-- Profile Information Section -->
|
||||
<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">Profile Information</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your personal details</p>
|
||||
</div>
|
||||
<form @submit.prevent="saveProfile" class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<!-- First Name -->
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="first_name" x-model="profileForm.first_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="last_name" x-model="profileForm.last_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" id="email" x-model="profileForm.email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<input type="tel" id="phone" x-model="profileForm.phone"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingProfile"
|
||||
class="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
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<svg x-show="savingProfile" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="savingProfile ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Section -->
|
||||
<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">Preferences</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your account preferences</p>
|
||||
</div>
|
||||
<form @submit.prevent="savePreferences" class="p-6 space-y-6">
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preferred Language
|
||||
</label>
|
||||
<select id="language" x-model="preferencesForm.preferred_language"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<option value="">Use shop default</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Francais</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="lb">Letzebuergesch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="checkbox" id="marketing_consent" x-model="preferencesForm.marketing_consent"
|
||||
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600
|
||||
focus:ring-2 focus:ring-primary
|
||||
dark:bg-gray-700"
|
||||
style="color: var(--color-primary)">
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="marketing_consent" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Marketing Communications
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Receive emails about new products, offers, and promotions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingPreferences"
|
||||
class="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
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<svg x-show="savingPreferences" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="savingPreferences ? 'Saving...' : 'Save Preferences'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<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">Change Password</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your account password</p>
|
||||
</div>
|
||||
<form @submit.prevent="changePassword" class="p-6 space-y-6">
|
||||
<!-- Current Password -->
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="current_password" x-model="passwordForm.current_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="new_password" x-model="passwordForm.new_password" required
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 8 characters with at least one letter and one number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p x-show="passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="mt-1 text-xs text-red-500">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Error -->
|
||||
<div x-show="passwordError" class="text-sm text-red-600 dark:text-red-400" x-text="passwordError"></div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="changingPassword || passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="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
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<svg x-show="changingPassword" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="changingPassword ? 'Changing...' : 'Change Password'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (read-only) -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Account Information</h3>
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Customer Number</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.customer_number || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Member Since</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatDate(profile?.created_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Orders</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.total_orders || 0"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Spent</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatPrice(profile?.total_spent)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopProfilePage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
profile: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
successMessage: '',
|
||||
|
||||
// Forms
|
||||
profileForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
},
|
||||
preferencesForm: {
|
||||
preferred_language: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
passwordForm: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
},
|
||||
|
||||
// Form states
|
||||
savingProfile: false,
|
||||
savingPreferences: false,
|
||||
changingPassword: false,
|
||||
passwordError: '',
|
||||
|
||||
async init() {
|
||||
await this.loadProfile();
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
|
||||
// Populate forms
|
||||
this.profileForm = {
|
||||
first_name: this.profile.first_name || '',
|
||||
last_name: this.profile.last_name || '',
|
||||
email: this.profile.email || '',
|
||||
phone: this.profile.phone || ''
|
||||
};
|
||||
this.preferencesForm = {
|
||||
preferred_language: this.profile.preferred_language || '',
|
||||
marketing_consent: this.profile.marketing_consent || false
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
this.error = err.message || 'Failed to load profile';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.profileForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Profile updated successfully';
|
||||
|
||||
// Update localStorage user data
|
||||
const userStr = localStorage.getItem('customer_user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
user.first_name = this.profile.first_name;
|
||||
user.last_name = this.profile.last_name;
|
||||
user.email = this.profile.email;
|
||||
localStorage.setItem('customer_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving profile:', err);
|
||||
this.error = err.message || 'Failed to save profile';
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences() {
|
||||
this.savingPreferences = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.preferencesForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save preferences');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Preferences updated successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving preferences:', err);
|
||||
this.error = err.message || 'Failed to save preferences';
|
||||
} finally {
|
||||
this.savingPreferences = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.passwordError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword = true;
|
||||
this.passwordError = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.passwordForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to change password');
|
||||
}
|
||||
|
||||
// Clear password form
|
||||
this.passwordForm = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
};
|
||||
|
||||
this.successMessage = 'Password changed successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error changing password:', err);
|
||||
this.passwordError = err.message || 'Failed to change password';
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
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: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
245
app/utils/vat.py
Normal file
245
app/utils/vat.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# app/utils/vat.py
|
||||
"""
|
||||
VAT calculation utilities for the OMS.
|
||||
|
||||
Provides centralized VAT logic used by both order_service and invoice_service
|
||||
to ensure consistency between order tax calculation and invoice VAT.
|
||||
|
||||
VAT Logic:
|
||||
- Luxembourg domestic: 17% (standard), 8% (reduced), 3% (super-reduced), 14% (intermediate)
|
||||
- EU cross-border B2C with OSS: Use destination country VAT rate
|
||||
- EU cross-border B2C without OSS: Use Luxembourg VAT rate (origin principle)
|
||||
- EU B2B with valid VAT number: Reverse charge (0% VAT)
|
||||
- Non-EU: VAT exempt (0%)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class VATRegime(str, Enum):
|
||||
"""VAT regime for order/invoice calculation."""
|
||||
|
||||
DOMESTIC = "domestic" # Same country as seller
|
||||
OSS = "oss" # EU cross-border with OSS registration
|
||||
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
|
||||
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
|
||||
EXEMPT = "exempt" # VAT exempt (non-EU)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VATResult:
|
||||
"""Result of VAT determination."""
|
||||
|
||||
regime: VATRegime
|
||||
rate: Decimal
|
||||
destination_country: str | None
|
||||
label: str | None
|
||||
|
||||
|
||||
# EU VAT rates by country code (2024 standard rates)
|
||||
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
|
||||
}
|
||||
|
||||
# Luxembourg specific VAT rates
|
||||
LU_VAT_RATES = {
|
||||
"standard": Decimal("17.00"),
|
||||
"intermediate": Decimal("14.00"),
|
||||
"reduced": Decimal("8.00"),
|
||||
"super_reduced": Decimal("3.00"),
|
||||
}
|
||||
|
||||
# Country names for labels
|
||||
COUNTRY_NAMES: dict[str, str] = {
|
||||
"AT": "Austria",
|
||||
"BE": "Belgium",
|
||||
"BG": "Bulgaria",
|
||||
"HR": "Croatia",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"EE": "Estonia",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"DE": "Germany",
|
||||
"GR": "Greece",
|
||||
"HU": "Hungary",
|
||||
"IE": "Ireland",
|
||||
"IT": "Italy",
|
||||
"LV": "Latvia",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MT": "Malta",
|
||||
"NL": "Netherlands",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"RO": "Romania",
|
||||
"SK": "Slovakia",
|
||||
"SI": "Slovenia",
|
||||
"ES": "Spain",
|
||||
"SE": "Sweden",
|
||||
}
|
||||
|
||||
|
||||
def get_vat_rate_for_country(country_iso: str) -> Decimal:
|
||||
"""
|
||||
Get standard VAT rate for EU country.
|
||||
|
||||
Args:
|
||||
country_iso: ISO 2-letter country code
|
||||
|
||||
Returns:
|
||||
VAT rate as Decimal (0.00 for non-EU countries)
|
||||
"""
|
||||
return EU_VAT_RATES.get(country_iso.upper(), Decimal("0.00"))
|
||||
|
||||
|
||||
def get_vat_rate_label(country_iso: str, vat_rate: Decimal) -> str:
|
||||
"""
|
||||
Get human-readable VAT rate label.
|
||||
|
||||
Args:
|
||||
country_iso: ISO 2-letter country code
|
||||
vat_rate: VAT rate as Decimal
|
||||
|
||||
Returns:
|
||||
Human-readable label (e.g., "Luxembourg VAT 17%")
|
||||
"""
|
||||
country_name = COUNTRY_NAMES.get(country_iso.upper(), country_iso)
|
||||
return f"{country_name} VAT {vat_rate}%"
|
||||
|
||||
|
||||
def is_eu_country(country_iso: str) -> bool:
|
||||
"""Check if country is in the EU."""
|
||||
return country_iso.upper() in EU_VAT_RATES
|
||||
|
||||
|
||||
def determine_vat_regime(
|
||||
seller_country: str,
|
||||
buyer_country: str,
|
||||
buyer_vat_number: str | None = None,
|
||||
seller_oss_registered: bool = False,
|
||||
) -> VATResult:
|
||||
"""
|
||||
Determine VAT regime and rate for an order/invoice.
|
||||
|
||||
VAT Decision Logic:
|
||||
1. Same country = domestic VAT
|
||||
2. B2B with valid VAT number = reverse charge (0%)
|
||||
3. Cross-border + OSS registered = destination country VAT
|
||||
4. Cross-border + no OSS = origin country VAT
|
||||
5. Non-EU = VAT exempt (0%)
|
||||
|
||||
Args:
|
||||
seller_country: Seller's country (ISO 2-letter code)
|
||||
buyer_country: Buyer's country (ISO 2-letter code)
|
||||
buyer_vat_number: Buyer's VAT number (for B2B detection)
|
||||
seller_oss_registered: Whether seller is registered for OSS
|
||||
|
||||
Returns:
|
||||
VATResult with regime, rate, destination country, and label
|
||||
"""
|
||||
seller_country = seller_country.upper() if seller_country else "LU"
|
||||
buyer_country = buyer_country.upper() if buyer_country else "LU"
|
||||
|
||||
# Same country = domestic VAT
|
||||
if seller_country == buyer_country:
|
||||
vat_rate = get_vat_rate_for_country(seller_country)
|
||||
label = get_vat_rate_label(seller_country, vat_rate) if vat_rate > 0 else None
|
||||
return VATResult(
|
||||
regime=VATRegime.DOMESTIC,
|
||||
rate=vat_rate,
|
||||
destination_country=None,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Different EU countries
|
||||
if is_eu_country(buyer_country):
|
||||
# B2B with valid VAT number = reverse charge
|
||||
if buyer_vat_number:
|
||||
return VATResult(
|
||||
regime=VATRegime.REVERSE_CHARGE,
|
||||
rate=Decimal("0.00"),
|
||||
destination_country=buyer_country,
|
||||
label="Reverse charge",
|
||||
)
|
||||
|
||||
# B2C cross-border
|
||||
if seller_oss_registered:
|
||||
# OSS: use destination country VAT
|
||||
vat_rate = get_vat_rate_for_country(buyer_country)
|
||||
label = get_vat_rate_label(buyer_country, vat_rate)
|
||||
return VATResult(
|
||||
regime=VATRegime.OSS,
|
||||
rate=vat_rate,
|
||||
destination_country=buyer_country,
|
||||
label=label,
|
||||
)
|
||||
else:
|
||||
# No OSS: use origin country VAT
|
||||
vat_rate = get_vat_rate_for_country(seller_country)
|
||||
label = get_vat_rate_label(seller_country, vat_rate)
|
||||
return VATResult(
|
||||
regime=VATRegime.ORIGIN,
|
||||
rate=vat_rate,
|
||||
destination_country=buyer_country,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Non-EU = VAT exempt
|
||||
return VATResult(
|
||||
regime=VATRegime.EXEMPT,
|
||||
rate=Decimal("0.00"),
|
||||
destination_country=buyer_country,
|
||||
label="VAT exempt",
|
||||
)
|
||||
|
||||
|
||||
def calculate_vat_amount(subtotal_cents: int, vat_rate: Decimal) -> int:
|
||||
"""
|
||||
Calculate VAT amount from subtotal.
|
||||
|
||||
Args:
|
||||
subtotal_cents: Subtotal in cents
|
||||
vat_rate: VAT rate as percentage (e.g., 17.00 for 17%)
|
||||
|
||||
Returns:
|
||||
VAT amount in cents
|
||||
"""
|
||||
if vat_rate <= 0:
|
||||
return 0
|
||||
|
||||
# Calculate: tax = subtotal * (rate / 100)
|
||||
subtotal_decimal = Decimal(str(subtotal_cents))
|
||||
tax_decimal = subtotal_decimal * (vat_rate / Decimal("100"))
|
||||
|
||||
# Round to nearest cent
|
||||
return int(round(tax_decimal))
|
||||
Reference in New Issue
Block a user