Files
orion/app/modules/checkout/templates/checkout/storefront/checkout.html
Samir Boulahtit a6e6d9be8e
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 46m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
refactor: rename shopLayoutData to storefrontLayoutData
Align Alpine.js base component naming with storefront terminology.
Updated across all storefront JS, templates, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:06:45 +01:00

916 lines
54 KiB
HTML

{# app/templates/storefront/checkout.html #}
{% extends "storefront/base.html" %}
{% block title %}Checkout - {{ store.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 }}" 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 }}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 }}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">Merchant</label>
<input type="text" x-model="shippingAddress.merchant"
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">Merchant</label>
<input type="text" x-model="billingAddress.merchant"
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.merchant" x-text="shippingAddress.merchant"></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.merchant" x-text="billingAddress.merchant"></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 loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/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 loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/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 {
...storefrontLayoutData(),
// 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: '',
merchant: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
// Billing address
billingAddress: {
first_name: '',
last_name: '',
merchant: '',
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 storefrontLayoutData === 'function') {
const baseData = storefrontLayoutData();
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/storefront/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/storefront/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.merchant = '';
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.merchant = savedAddr.merchant || '';
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/storefront/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,
merchant: this.shippingAddress.merchant || 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/storefront/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,
merchant: this.billingAddress.merchant || 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/storefront/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,
merchant: this.shippingAddress.merchant || 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,
merchant: this.billingAddress.merchant || 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/storefront/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 }}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 %}