refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ 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.modules.tenancy.exceptions import VendorNotFoundException
|
||||
from app.modules.cart.services import cart_service
|
||||
from app.modules.checkout.schemas import (
|
||||
CheckoutRequest,
|
||||
@@ -28,7 +28,7 @@ from app.modules.checkout.schemas import (
|
||||
from app.modules.checkout.services import checkout_service
|
||||
from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.orders.services import order_service
|
||||
from app.services.email_service import EmailService # noqa: MOD-004 - Core email service
|
||||
from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.orders.schemas import OrderCreate, OrderResponse
|
||||
|
||||
2
app/modules/checkout/routes/pages/__init__.py
Normal file
2
app/modules/checkout/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/checkout/routes/pages/__init__.py
|
||||
"""Checkout module page routes."""
|
||||
46
app/modules/checkout/routes/pages/storefront.py
Normal file
46
app/modules/checkout/routes/pages/storefront.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/modules/checkout/routes/pages/storefront.py
|
||||
"""
|
||||
Checkout Storefront Page Routes (HTML rendering).
|
||||
|
||||
Storefront (customer shop) pages for checkout:
|
||||
- Checkout page
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.modules.core.utils.page_context import get_storefront_context
|
||||
from app.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CHECKOUT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/checkout", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_checkout_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render checkout page.
|
||||
Handles shipping, payment, and order confirmation.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_checkout_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"checkout/storefront/checkout.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
915
app/modules/checkout/templates/checkout/storefront/checkout.html
Normal file
915
app/modules/checkout/templates/checkout/storefront/checkout.html
Normal file
@@ -0,0 +1,915 @@
|
||||
{# app/templates/storefront/checkout.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Checkout - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}checkoutPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{# Breadcrumbs #}
|
||||
<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/" class="hover:text-primary">Home</a></li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<span class="text-gray-900 dark:text-white">Checkout</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# Page Header #}
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
||||
|
||||
{# 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>
|
||||
|
||||
{# Empty Cart #}
|
||||
<div x-show="!loading && cartItems.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 mb-4 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Checkout Form #}
|
||||
<div x-show="!loading && cartItems.length > 0" x-cloak>
|
||||
<form @submit.prevent="placeOrder()" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{# Left Column - Forms #}
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
|
||||
{# Step Indicator #}
|
||||
<div class="flex items-center justify-center mb-8">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" style="background-color: var(--color-primary)">1</div>
|
||||
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Information</span>
|
||||
</div>
|
||||
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 2 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 2 ? 'background-color: var(--color-primary)' : ''">2</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="step >= 2 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Shipping</span>
|
||||
</div>
|
||||
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 3 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 3 ? 'background-color: var(--color-primary)' : ''">3</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="step >= 3 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Review</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Error Message #}
|
||||
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<span class="h-5 w-5 text-red-400" x-html="$icon('x-circle', 'h-5 w-5')"></span>
|
||||
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step 1: Contact & Shipping Address #}
|
||||
<div x-show="step === 1" class="space-y-6">
|
||||
|
||||
{# Contact Information #}
|
||||
<div class="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-4">Contact Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||
<input type="text" x-model="customer.first_name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||
<input type="text" x-model="customer.last_name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email *</label>
|
||||
<input type="email" x-model="customer.email" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
|
||||
<input type="tel" x-model="customer.phone"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Shipping Address #}
|
||||
<div class="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-4">Shipping Address</h2>
|
||||
|
||||
{# Saved Addresses Selector (only shown for logged in customers) #}
|
||||
<div x-show="isLoggedIn && shippingAddresses.length > 0" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
|
||||
<select x-model="selectedShippingAddressId" @change="populateFromSavedAddress('shipping')"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Enter a new address</option>
|
||||
<template x-for="addr in shippingAddresses" :key="addr.id">
|
||||
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||
<input type="text" x-model="shippingAddress.first_name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||
<input type="text" x-model="shippingAddress.last_name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
|
||||
<input type="text" x-model="shippingAddress.company"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
|
||||
<input type="text" x-model="shippingAddress.address_line_1" required placeholder="Street and number"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
|
||||
<input type="text" x-model="shippingAddress.address_line_2" placeholder="Apartment, suite, etc."
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
|
||||
<input type="text" x-model="shippingAddress.postal_code" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
|
||||
<input type="text" x-model="shippingAddress.city" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
|
||||
<select x-model="shippingAddress.country_iso" required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select a country</option>
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" x-text="country.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Save Address Checkbox (only for new addresses when logged in) #}
|
||||
<div x-show="isLoggedIn && !selectedShippingAddressId" class="md:col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="saveShippingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
|
||||
</label>
|
||||
</div>
|
||||
</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">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="sameAsShipping" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Same as shipping</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# Saved Addresses Selector (only shown for logged in customers when not same as shipping) #}
|
||||
<div x-show="isLoggedIn && !sameAsShipping && billingAddresses.length > 0" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
|
||||
<select x-model="selectedBillingAddressId" @change="populateFromSavedAddress('billing')"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Enter a new address</option>
|
||||
<template x-for="addr in billingAddresses" :key="addr.id">
|
||||
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div x-show="!sameAsShipping" x-collapse class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||
<input type="text" x-model="billingAddress.first_name" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||
<input type="text" x-model="billingAddress.last_name" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
|
||||
<input type="text" x-model="billingAddress.company"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
|
||||
<input type="text" x-model="billingAddress.address_line_1" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
|
||||
<input type="text" x-model="billingAddress.address_line_2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
|
||||
<input type="text" x-model="billingAddress.postal_code" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
|
||||
<input type="text" x-model="billingAddress.city" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
|
||||
<select x-model="billingAddress.country_iso" :required="!sameAsShipping"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select a country</option>
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" x-text="country.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Save Address Checkbox (only for new addresses when logged in) #}
|
||||
<div x-show="isLoggedIn && !selectedBillingAddressId" class="md:col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="saveBillingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="goToStep(2)"
|
||||
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
Continue to Shipping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step 2: Shipping Method #}
|
||||
<div x-show="step === 2" class="space-y-6">
|
||||
<div class="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-4">Shipping Method</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="shippingMethod === 'standard' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
|
||||
:style="shippingMethod === 'standard' ? 'border-color: var(--color-primary)' : ''">
|
||||
<input type="radio" name="shipping" value="standard" x-model="shippingMethod" class="hidden">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Standard Shipping</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">3-5 business days</p>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 dark:text-white" x-text="subtotal >= 50 ? 'FREE' : '5.99'"></span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="shippingMethod === 'express' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
|
||||
:style="shippingMethod === 'express' ? 'border-color: var(--color-primary)' : ''">
|
||||
<input type="radio" name="shipping" value="express" x-model="shippingMethod" class="hidden">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Express Shipping</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">1-2 business days</p>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">9.99</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Order Notes #}
|
||||
<div class="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-4">Order Notes (Optional)</h2>
|
||||
<textarea x-model="customerNotes" rows="3" placeholder="Special instructions for your order..."
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="button" @click="goToStep(1)"
|
||||
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" @click="goToStep(3)"
|
||||
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
Review Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step 3: Review & Place Order #}
|
||||
<div x-show="step === 3" class="space-y-6">
|
||||
{# Review Contact Info #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Contact Information</h2>
|
||||
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300" x-text="customer.first_name + ' ' + customer.last_name"></p>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
|
||||
<p x-show="customer.phone" class="text-gray-600 dark:text-gray-400" x-text="customer.phone"></p>
|
||||
</div>
|
||||
|
||||
{# Review Addresses #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Address</h2>
|
||||
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="shippingAddress.first_name + ' ' + shippingAddress.last_name"></p>
|
||||
<p x-show="shippingAddress.company" x-text="shippingAddress.company"></p>
|
||||
<p x-text="shippingAddress.address_line_1"></p>
|
||||
<p x-show="shippingAddress.address_line_2" x-text="shippingAddress.address_line_2"></p>
|
||||
<p x-text="shippingAddress.postal_code + ' ' + shippingAddress.city"></p>
|
||||
<p x-text="getCountryName(shippingAddress.country_iso)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
|
||||
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
|
||||
<template x-if="sameAsShipping">
|
||||
<p class="italic">Same as shipping address</p>
|
||||
</template>
|
||||
<template x-if="!sameAsShipping">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="billingAddress.first_name + ' ' + billingAddress.last_name"></p>
|
||||
<p x-show="billingAddress.company" x-text="billingAddress.company"></p>
|
||||
<p x-text="billingAddress.address_line_1"></p>
|
||||
<p x-show="billingAddress.address_line_2" x-text="billingAddress.address_line_2"></p>
|
||||
<p x-text="billingAddress.postal_code + ' ' + billingAddress.city"></p>
|
||||
<p x-text="getCountryName(billingAddress.country_iso)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Review Shipping #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Method</h2>
|
||||
<button type="button" @click="goToStep(2)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300" x-text="shippingMethod === 'express' ? 'Express Shipping (1-2 business days)' : 'Standard Shipping (3-5 business days)'"></p>
|
||||
</div>
|
||||
|
||||
{# Order Items Review #}
|
||||
<div class="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-4">Order Items</h2>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="item in cartItems" :key="item.product_id">
|
||||
<div class="py-4 flex items-center gap-4">
|
||||
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
class="w-16 h-16 object-cover rounded-lg">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Qty: <span x-text="item.quantity"></span></p>
|
||||
</div>
|
||||
<p class="font-semibold text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="button" @click="goToStep(2)"
|
||||
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting"
|
||||
class="px-8 py-3 text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="!submitting">Place Order</span>
|
||||
<span x-show="submitting" class="flex items-center">
|
||||
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('spinner', 'h-5 w-5 animate-spin')"></span>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# Right Column - Order Summary #}
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 sticky top-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
Order Summary
|
||||
</h3>
|
||||
|
||||
{# Cart Items Preview #}
|
||||
<div class="space-y-3 mb-6 max-h-64 overflow-y-auto">
|
||||
<template x-for="item in cartItems" :key="item.product_id">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
class="w-12 h-12 object-cover rounded">
|
||||
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Totals #}
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + subtotal.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="shippingCost === 0 ? 'FREE' : '€' + shippingCost.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Tax (incl.)</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + tax.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-lg font-bold pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-gray-900 dark:text-white">Total</span>
|
||||
<span style="color: var(--color-primary)" x-text="'€' + total.toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||||
Free shipping on orders over €50
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function checkoutPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
loading: true,
|
||||
submitting: false,
|
||||
error: '',
|
||||
step: 1,
|
||||
|
||||
// Cart data
|
||||
cartItems: [],
|
||||
|
||||
// Customer info
|
||||
customer: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
},
|
||||
|
||||
// Saved addresses (for logged in customers)
|
||||
isLoggedIn: false,
|
||||
savedAddresses: [],
|
||||
selectedShippingAddressId: '',
|
||||
selectedBillingAddressId: '',
|
||||
saveShippingAddress: false,
|
||||
saveBillingAddress: false,
|
||||
|
||||
// Computed filtered addresses by type
|
||||
get shippingAddresses() {
|
||||
return this.savedAddresses.filter(a => a.address_type === 'shipping');
|
||||
},
|
||||
get billingAddresses() {
|
||||
return this.savedAddresses.filter(a => a.address_type === 'billing');
|
||||
},
|
||||
|
||||
// Shipping address
|
||||
shippingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country_iso: 'LU'
|
||||
},
|
||||
|
||||
// Billing address
|
||||
billingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country_iso: 'LU'
|
||||
},
|
||||
|
||||
sameAsShipping: true,
|
||||
shippingMethod: 'standard',
|
||||
customerNotes: '',
|
||||
|
||||
// Countries list
|
||||
countries: [
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'CY', name: 'Cyprus' }
|
||||
],
|
||||
|
||||
// Computed
|
||||
get subtotal() {
|
||||
return this.cartItems.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
|
||||
},
|
||||
|
||||
get shippingCost() {
|
||||
if (this.shippingMethod === 'express') return 9.99;
|
||||
return this.subtotal >= 50 ? 0 : 5.99;
|
||||
},
|
||||
|
||||
get tax() {
|
||||
// VAT is included in price, calculate the VAT portion (17% for LU)
|
||||
const vatRate = 0.17;
|
||||
return this.subtotal * vatRate / (1 + vatRate);
|
||||
},
|
||||
|
||||
get total() {
|
||||
return this.subtotal + this.shippingCost;
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[CHECKOUT] Initializing...');
|
||||
|
||||
// Initialize session
|
||||
if (typeof shopLayoutData === 'function') {
|
||||
const baseData = shopLayoutData();
|
||||
if (baseData.init) {
|
||||
baseData.init.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if customer is logged in and pre-fill data
|
||||
await this.loadCustomerData();
|
||||
|
||||
// Load cart
|
||||
await this.loadCart();
|
||||
},
|
||||
|
||||
async loadCustomerData() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/me');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// Pre-fill customer info
|
||||
this.customer.first_name = data.first_name || '';
|
||||
this.customer.last_name = data.last_name || '';
|
||||
this.customer.email = data.email || '';
|
||||
this.customer.phone = data.phone || '';
|
||||
|
||||
// Pre-fill shipping address with customer name
|
||||
this.shippingAddress.first_name = data.first_name || '';
|
||||
this.shippingAddress.last_name = data.last_name || '';
|
||||
|
||||
console.log('[CHECKOUT] Customer data loaded');
|
||||
|
||||
// Load saved addresses for logged in customer
|
||||
await this.loadSavedAddresses();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CHECKOUT] No customer logged in or error:', error);
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSavedAddresses() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/addresses');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.savedAddresses = data.addresses || [];
|
||||
console.log('[CHECKOUT] Saved addresses loaded:', this.savedAddresses.length);
|
||||
|
||||
// Auto-select default shipping address
|
||||
const defaultShipping = this.shippingAddresses.find(a => a.is_default);
|
||||
if (defaultShipping) {
|
||||
this.selectedShippingAddressId = defaultShipping.id;
|
||||
this.populateFromSavedAddress('shipping');
|
||||
}
|
||||
|
||||
// Auto-select default billing address
|
||||
const defaultBilling = this.billingAddresses.find(a => a.is_default);
|
||||
if (defaultBilling) {
|
||||
this.selectedBillingAddressId = defaultBilling.id;
|
||||
this.populateFromSavedAddress('billing');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHECKOUT] Failed to load saved addresses:', error);
|
||||
}
|
||||
},
|
||||
|
||||
populateFromSavedAddress(type) {
|
||||
const addressId = type === 'shipping' ? this.selectedShippingAddressId : this.selectedBillingAddressId;
|
||||
const addresses = type === 'shipping' ? this.shippingAddresses : this.billingAddresses;
|
||||
const targetAddress = type === 'shipping' ? this.shippingAddress : this.billingAddress;
|
||||
|
||||
if (!addressId) {
|
||||
// Clear form when "Enter a new address" is selected
|
||||
targetAddress.first_name = type === 'shipping' ? this.customer.first_name : '';
|
||||
targetAddress.last_name = type === 'shipping' ? this.customer.last_name : '';
|
||||
targetAddress.company = '';
|
||||
targetAddress.address_line_1 = '';
|
||||
targetAddress.address_line_2 = '';
|
||||
targetAddress.city = '';
|
||||
targetAddress.postal_code = '';
|
||||
targetAddress.country_iso = 'LU';
|
||||
return;
|
||||
}
|
||||
|
||||
const savedAddr = addresses.find(a => a.id == addressId);
|
||||
if (savedAddr) {
|
||||
targetAddress.first_name = savedAddr.first_name || '';
|
||||
targetAddress.last_name = savedAddr.last_name || '';
|
||||
targetAddress.company = savedAddr.company || '';
|
||||
targetAddress.address_line_1 = savedAddr.address_line_1 || '';
|
||||
targetAddress.address_line_2 = savedAddr.address_line_2 || '';
|
||||
targetAddress.city = savedAddr.city || '';
|
||||
targetAddress.postal_code = savedAddr.postal_code || '';
|
||||
targetAddress.country_iso = savedAddr.country_iso || 'LU';
|
||||
console.log(`[CHECKOUT] Populated ${type} address from saved:`, savedAddr.id);
|
||||
}
|
||||
},
|
||||
|
||||
formatAddressOption(addr) {
|
||||
const name = `${addr.first_name} ${addr.last_name}`.trim();
|
||||
const location = `${addr.address_line_1}, ${addr.postal_code} ${addr.city}`;
|
||||
const defaultBadge = addr.is_default ? ' (Default)' : '';
|
||||
return `${name} - ${location}${defaultBadge}`;
|
||||
},
|
||||
|
||||
async loadCart() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.cartItems = data.items || [];
|
||||
console.log('[CHECKOUT] Cart loaded:', this.cartItems.length, 'items');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHECKOUT] Failed to load cart:', error);
|
||||
this.error = 'Failed to load cart';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
goToStep(newStep) {
|
||||
// Validate current step before moving forward
|
||||
if (newStep > this.step) {
|
||||
if (this.step === 1 && !this.validateStep1()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.step = newStep;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
validateStep1() {
|
||||
// Validate customer info
|
||||
if (!this.customer.first_name || !this.customer.last_name || !this.customer.email) {
|
||||
this.error = 'Please fill in all required contact fields';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(this.customer.email)) {
|
||||
this.error = 'Please enter a valid email address';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate shipping address
|
||||
if (!this.shippingAddress.first_name || !this.shippingAddress.last_name ||
|
||||
!this.shippingAddress.address_line_1 || !this.shippingAddress.city ||
|
||||
!this.shippingAddress.postal_code || !this.shippingAddress.country_iso) {
|
||||
this.error = 'Please fill in all required shipping address fields';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate billing address if not same as shipping
|
||||
if (!this.sameAsShipping) {
|
||||
if (!this.billingAddress.first_name || !this.billingAddress.last_name ||
|
||||
!this.billingAddress.address_line_1 || !this.billingAddress.city ||
|
||||
!this.billingAddress.postal_code || !this.billingAddress.country_iso) {
|
||||
this.error = 'Please fill in all required billing address fields';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.error = '';
|
||||
return true;
|
||||
},
|
||||
|
||||
getCountryName(code) {
|
||||
const country = this.countries.find(c => c.code === code);
|
||||
return country ? country.name : code;
|
||||
},
|
||||
|
||||
async saveNewAddresses() {
|
||||
// Save shipping address if checkbox is checked and it's a new address
|
||||
if (this.saveShippingAddress && !this.selectedShippingAddressId) {
|
||||
try {
|
||||
const country = this.countries.find(c => c.code === this.shippingAddress.country_iso);
|
||||
const addressData = {
|
||||
address_type: 'shipping',
|
||||
first_name: this.shippingAddress.first_name,
|
||||
last_name: this.shippingAddress.last_name,
|
||||
company: this.shippingAddress.company || null,
|
||||
address_line_1: this.shippingAddress.address_line_1,
|
||||
address_line_2: this.shippingAddress.address_line_2 || null,
|
||||
city: this.shippingAddress.city,
|
||||
postal_code: this.shippingAddress.postal_code,
|
||||
country_name: country ? country.name : this.shippingAddress.country_iso,
|
||||
country_iso: this.shippingAddress.country_iso,
|
||||
is_default: this.shippingAddresses.length === 0 // Make default if first address
|
||||
};
|
||||
const response = await fetch('/api/v1/shop/addresses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(addressData)
|
||||
});
|
||||
if (response.ok) {
|
||||
console.log('[CHECKOUT] Shipping address saved');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHECKOUT] Failed to save shipping address:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save billing address if checkbox is checked, it's a new address, and not same as shipping
|
||||
if (this.saveBillingAddress && !this.selectedBillingAddressId && !this.sameAsShipping) {
|
||||
try {
|
||||
const country = this.countries.find(c => c.code === this.billingAddress.country_iso);
|
||||
const addressData = {
|
||||
address_type: 'billing',
|
||||
first_name: this.billingAddress.first_name,
|
||||
last_name: this.billingAddress.last_name,
|
||||
company: this.billingAddress.company || null,
|
||||
address_line_1: this.billingAddress.address_line_1,
|
||||
address_line_2: this.billingAddress.address_line_2 || null,
|
||||
city: this.billingAddress.city,
|
||||
postal_code: this.billingAddress.postal_code,
|
||||
country_name: country ? country.name : this.billingAddress.country_iso,
|
||||
country_iso: this.billingAddress.country_iso,
|
||||
is_default: this.billingAddresses.length === 0 // Make default if first address
|
||||
};
|
||||
const response = await fetch('/api/v1/shop/addresses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(addressData)
|
||||
});
|
||||
if (response.ok) {
|
||||
console.log('[CHECKOUT] Billing address saved');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHECKOUT] Failed to save billing address:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async placeOrder() {
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
|
||||
try {
|
||||
// Save new addresses if requested (only for logged in users with new addresses)
|
||||
if (this.isLoggedIn) {
|
||||
await this.saveNewAddresses();
|
||||
}
|
||||
|
||||
// Build order data
|
||||
const orderData = {
|
||||
items: this.cartItems.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
customer: {
|
||||
first_name: this.customer.first_name,
|
||||
last_name: this.customer.last_name,
|
||||
email: this.customer.email,
|
||||
phone: this.customer.phone || null
|
||||
},
|
||||
shipping_address: {
|
||||
first_name: this.shippingAddress.first_name,
|
||||
last_name: this.shippingAddress.last_name,
|
||||
company: this.shippingAddress.company || null,
|
||||
address_line_1: this.shippingAddress.address_line_1,
|
||||
address_line_2: this.shippingAddress.address_line_2 || null,
|
||||
city: this.shippingAddress.city,
|
||||
postal_code: this.shippingAddress.postal_code,
|
||||
country_iso: this.shippingAddress.country_iso
|
||||
},
|
||||
billing_address: this.sameAsShipping ? null : {
|
||||
first_name: this.billingAddress.first_name,
|
||||
last_name: this.billingAddress.last_name,
|
||||
company: this.billingAddress.company || null,
|
||||
address_line_1: this.billingAddress.address_line_1,
|
||||
address_line_2: this.billingAddress.address_line_2 || null,
|
||||
city: this.billingAddress.city,
|
||||
postal_code: this.billingAddress.postal_code,
|
||||
country_iso: this.billingAddress.country_iso
|
||||
},
|
||||
shipping_method: this.shippingMethod,
|
||||
customer_notes: this.customerNotes || null,
|
||||
session_id: this.sessionId
|
||||
};
|
||||
|
||||
console.log('[CHECKOUT] Placing order:', orderData);
|
||||
|
||||
const response = await fetch('/api/v1/shop/orders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to place order');
|
||||
}
|
||||
|
||||
const order = await response.json();
|
||||
console.log('[CHECKOUT] Order placed:', order.order_number);
|
||||
|
||||
// Redirect to confirmation page
|
||||
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[CHECKOUT] Error placing order:', error);
|
||||
this.error = error.message || 'Failed to place order. Please try again.';
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user