feat: add customer multiple addresses management

- Add CustomerAddressService with CRUD operations
- Add shop API endpoints for address management (GET, POST, PUT, DELETE)
- Add set default endpoint for address type
- Implement addresses.html with full UI (cards, modals, Alpine.js)
- Integrate saved addresses in checkout flow
  - Address selector dropdowns for shipping/billing
  - Auto-select default addresses
  - Save new address checkbox option
- Add country_iso field alongside country_name
- Add address exceptions (NotFound, LimitExceeded, InvalidType)
- Max 10 addresses per customer limit
- One default address per type (shipping/billing)
- Add unit tests for CustomerAddressService
- Add integration tests for shop addresses API

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 19:16:35 +01:00
parent ea0218746f
commit b5b32fb351
15 changed files with 3940 additions and 17 deletions

View File

@@ -1,15 +1,562 @@
{# app/templates/shop/account/addresses.html #}
{% extends "shop/base.html" %}
{% block title %}My Addresses{% endblock %}
{% block title %}My Addresses - {{ vendor.name }}{% endblock %}
{% block alpine_data %}addressesPage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
<!-- Page Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Addresses</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your shipping and billing addresses</p>
</div>
<button @click="openAddModal()"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
style="background-color: var(--color-primary)">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Address
</button>
</div>
{# TODO: Implement address management #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary" style="color: var(--color-primary)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-400" x-text="error"></p>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<button @click="openAddModal()"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark"
style="background-color: var(--color-primary)">
Add Your First Address
</button>
</div>
<!-- Addresses Grid -->
<div x-show="!loading && addresses.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="address in addresses" :key="address.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 relative">
<!-- Default Badge -->
<div x-show="address.is_default" class="absolute top-4 right-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="address.address_type === 'shipping' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'">
<svg class="-ml-0.5 mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Default <span x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'" class="ml-1"></span>
</span>
</div>
<!-- Address Type Badge (non-default) -->
<div x-show="!address.is_default" class="absolute top-4 right-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'"></span>
</div>
<!-- Address Content -->
<div class="pr-24">
<p class="text-lg font-medium text-gray-900 dark:text-white" x-text="address.first_name + ' ' + address.last_name"></p>
<p x-show="address.company" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.company"></p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_1"></p>
<p x-show="address.address_line_2" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_2"></p>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.postal_code + ' ' + address.city"></p>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.country_name"></p>
</div>
<!-- Actions -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center space-x-4">
<button @click="openEditModal(address)"
class="text-sm font-medium text-primary hover:text-primary-dark"
style="color: var(--color-primary)">
Edit
</button>
<button x-show="!address.is_default"
@click="setAsDefault(address.id)"
class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Set as Default
</button>
<button @click="openDeleteModal(address.id)"
class="text-sm font-medium text-red-600 hover:text-red-700">
Delete
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Add/Edit Address Modal -->
<div x-show="showAddressModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay -->
<div x-show="showAddressModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showAddressModal = false"
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showAddressModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="absolute top-0 right-0 pt-4 pr-4">
<button @click="showAddressModal = false" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="sm:flex sm:items-start">
<div class="w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-6"
x-text="editingAddress ? 'Edit Address' : 'Add New Address'"></h3>
<form @submit.prevent="saveAddress()" class="space-y-4">
<!-- Address Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Type</label>
<select x-model="addressForm.address_type"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
<option value="shipping">Shipping Address</option>
<option value="billing">Billing Address</option>
</select>
</div>
<!-- Name Row -->
<div class="grid 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="addressForm.first_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</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="addressForm.last_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
</div>
<!-- Company -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company (optional)</label>
<input type="text" x-model="addressForm.company"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 1 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="addressForm.address_line_1" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 2 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2 (optional)</label>
<input type="text" x-model="addressForm.address_line_2"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- City & Postal Code -->
<div class="grid grid-cols-2 gap-4">
<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="addressForm.postal_code" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</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="addressForm.city" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
</div>
<!-- Country -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="addressForm.country_iso"
@change="addressForm.country_name = countries.find(c => c.iso === addressForm.country_iso)?.name || ''"
required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
<template x-for="country in countries" :key="country.iso">
<option :value="country.iso" x-text="country.name"></option>
</template>
</select>
</div>
<!-- Default Checkbox -->
<div class="flex items-center">
<input type="checkbox" x-model="addressForm.is_default"
class="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
style="color: var(--color-primary)">
<label class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Set as default <span x-text="addressForm.address_type === 'shipping' ? 'shipping' : 'billing'"></span> address
</label>
</div>
<!-- Error Message -->
<div x-show="formError" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
<p class="text-sm text-red-700 dark:text-red-400" x-text="formError"></p>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end space-x-3">
<button type="button" @click="showAddressModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
Cancel
</button>
<button type="submit"
:disabled="saving"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark disabled:opacity-50"
style="background-color: var(--color-primary)">
<span x-show="!saving" x-text="editingAddress ? 'Save Changes' : 'Add Address'"></span>
<span x-show="saving">Saving...</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay -->
<div x-show="showDeleteModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showDeleteModal = false"
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showDeleteModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Delete Address</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to delete this address? This action cannot be undone.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button @click="confirmDelete()"
:disabled="deleting"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50">
<span x-show="!deleting">Delete</span>
<span x-show="deleting">Deleting...</span>
</button>
<button @click="showDeleteModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function addressesPage() {
return {
...shopLayoutData(),
// State
loading: true,
error: '',
addresses: [],
// Modal state
showAddressModal: false,
showDeleteModal: false,
editingAddress: null,
deletingAddressId: null,
saving: false,
deleting: false,
formError: '',
// Form data
addressForm: {
address_type: 'shipping',
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_name: 'Luxembourg',
country_iso: 'LU',
is_default: false
},
// Countries list
countries: [
{ iso: 'LU', name: 'Luxembourg' },
{ iso: 'DE', name: 'Germany' },
{ iso: 'FR', name: 'France' },
{ iso: 'BE', name: 'Belgium' },
{ iso: 'NL', name: 'Netherlands' },
{ iso: 'AT', name: 'Austria' },
{ iso: 'IT', name: 'Italy' },
{ iso: 'ES', name: 'Spain' },
{ iso: 'PT', name: 'Portugal' },
{ iso: 'PL', name: 'Poland' },
{ iso: 'CZ', name: 'Czech Republic' },
{ iso: 'SK', name: 'Slovakia' },
{ iso: 'HU', name: 'Hungary' },
{ iso: 'RO', name: 'Romania' },
{ iso: 'BG', name: 'Bulgaria' },
{ iso: 'GR', name: 'Greece' },
{ iso: 'HR', name: 'Croatia' },
{ iso: 'SI', name: 'Slovenia' },
{ iso: 'EE', name: 'Estonia' },
{ iso: 'LV', name: 'Latvia' },
{ iso: 'LT', name: 'Lithuania' },
{ iso: 'FI', name: 'Finland' },
{ iso: 'SE', name: 'Sweden' },
{ iso: 'DK', name: 'Denmark' },
{ iso: 'IE', name: 'Ireland' },
{ iso: 'CY', name: 'Cyprus' },
{ iso: 'MT', name: 'Malta' },
{ iso: 'GB', name: 'United Kingdom' },
{ iso: 'CH', name: 'Switzerland' },
],
async init() {
await this.loadAddresses();
},
async loadAddresses() {
this.loading = true;
this.error = '';
try {
const token = localStorage.getItem('customer_token');
const response = await fetch('/api/v1/shop/addresses', {
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_url }}shop/account/login';
return;
}
throw new Error('Failed to load addresses');
}
const data = await response.json();
this.addresses = data.addresses;
} catch (err) {
console.error('[ADDRESSES] Error loading:', err);
this.error = 'Failed to load addresses. Please try again.';
} finally {
this.loading = false;
}
},
openAddModal() {
this.editingAddress = null;
this.formError = '';
this.addressForm = {
address_type: 'shipping',
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_name: 'Luxembourg',
country_iso: 'LU',
is_default: false
};
this.showAddressModal = true;
},
openEditModal(address) {
this.editingAddress = address;
this.formError = '';
this.addressForm = {
address_type: address.address_type,
first_name: address.first_name,
last_name: address.last_name,
company: address.company || '',
address_line_1: address.address_line_1,
address_line_2: address.address_line_2 || '',
city: address.city,
postal_code: address.postal_code,
country_name: address.country_name,
country_iso: address.country_iso,
is_default: address.is_default
};
this.showAddressModal = true;
},
async saveAddress() {
this.saving = true;
this.formError = '';
try {
const token = localStorage.getItem('customer_token');
const url = this.editingAddress
? `/api/v1/shop/addresses/${this.editingAddress.id}`
: '/api/v1/shop/addresses';
const method = this.editingAddress ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
},
body: JSON.stringify(this.addressForm)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || data.message || 'Failed to save address');
}
this.showAddressModal = false;
this.showToast(this.editingAddress ? 'Address updated' : 'Address added', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error saving:', err);
this.formError = err.message || 'Failed to save address. Please try again.';
} finally {
this.saving = false;
}
},
openDeleteModal(addressId) {
this.deletingAddressId = addressId;
this.showDeleteModal = true;
},
async confirmDelete() {
this.deleting = true;
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${this.deletingAddressId}`, {
method: 'DELETE',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
throw new Error('Failed to delete address');
}
this.showDeleteModal = false;
this.showToast('Address deleted', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error deleting:', err);
this.showToast('Failed to delete address', 'error');
} finally {
this.deleting = false;
}
},
async setAsDefault(addressId) {
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${addressId}/default`, {
method: 'PUT',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
throw new Error('Failed to set default address');
}
this.showToast('Default address updated', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error setting default:', err);
this.showToast('Failed to set default address', 'error');
}
}
}
}
</script>
{% endblock %}

View File

@@ -1,15 +1,926 @@
{# app/templates/shop/checkout.html #}
{% extends "shop/base.html" %}
{% block title %}Checkout{% endblock %}
{% 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">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
{# TODO: Implement checkout process #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
{# 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">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
</li>
<li class="flex items-center">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900 dark:text-white">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">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h3 class="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">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
{# 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">
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
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 %}