Rename all "shop" directories and references to "storefront" to match the API and route naming convention already in use. Renamed directories: - app/templates/shop/ → app/templates/storefront/ - static/shop/ → static/storefront/ - app/templates/shared/macros/shop/ → .../macros/storefront/ - docs/frontend/shop/ → docs/frontend/storefront/ Renamed files: - shop.css → storefront.css - shop-layout.js → storefront-layout.js Updated references in: - app/routes/storefront_pages.py (21 template references) - app/modules/cms/routes/pages/vendor.py - app/templates/storefront/base.html (static paths) - All storefront templates (extends/includes) - docs/architecture/frontend-structure.md This aligns the template/static naming with: - Route file: storefront_pages.py - API directory: app/api/v1/storefront/ - Module routes: */routes/api/storefront.py - URL paths: /storefront/* Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
916 lines
54 KiB
HTML
916 lines
54 KiB
HTML
{# 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 %}
|