- 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>
563 lines
28 KiB
HTML
563 lines
28 KiB
HTML
{# app/templates/shop/account/addresses.html #}
|
|
{% extends "shop/base.html" %}
|
|
|
|
{% 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">
|
|
<!-- 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>
|
|
|
|
<!-- 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">​</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">​</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 %}
|