refactor: rename shop to storefront for consistency
Rename all "shop" directories and references to "storefront" to match the API and route naming convention already in use. Renamed directories: - app/templates/shop/ → app/templates/storefront/ - static/shop/ → static/storefront/ - app/templates/shared/macros/shop/ → .../macros/storefront/ - docs/frontend/shop/ → docs/frontend/storefront/ Renamed files: - shop.css → storefront.css - shop-layout.js → storefront-layout.js Updated references in: - app/routes/storefront_pages.py (21 template references) - app/modules/cms/routes/pages/vendor.py - app/templates/storefront/base.html (static paths) - All storefront templates (extends/includes) - docs/architecture/frontend-structure.md This aligns the template/static naming with: - Route file: storefront_pages.py - API directory: app/api/v1/storefront/ - Module routes: */routes/api/storefront.py - URL paths: /storefront/* Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
549
app/templates/storefront/account/addresses.html
Normal file
549
app/templates/storefront/account/addresses.html
Normal file
@@ -0,0 +1,549 @@
|
||||
{# app/templates/storefront/account/addresses.html #}
|
||||
{% extends "storefront/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)">
|
||||
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('plus', 'h-5 w-5')"></span>
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('spinner', 'h-8 w-8 animate-spin')"></span>
|
||||
</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">
|
||||
<span class="h-5 w-5 text-red-400" x-html="$icon('exclamation-circle', 'h-5 w-5')"></span>
|
||||
<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">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
|
||||
<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'">
|
||||
<span class="-ml-0.5 mr-1 h-3 w-3" x-html="$icon('check-circle', 'h-3 w-3')"></span>
|
||||
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 -->
|
||||
{# noqa: FE-004 - Complex form modal with dynamic title and extensive form fields not suited for form_modal macro #}
|
||||
<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"
|
||||
@click.stop
|
||||
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">
|
||||
<span class="h-6 w-6" x-html="$icon('x-mark', 'h-6 w-6')"></span>
|
||||
</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"
|
||||
@click.stop
|
||||
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">
|
||||
<span class="h-6 w-6 text-red-600 dark:text-red-400" x-html="$icon('exclamation-triangle', 'h-6 w-6')"></span>
|
||||
</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 %}
|
||||
183
app/templates/storefront/account/dashboard.html
Normal file
183
app/templates/storefront/account/dashboard.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{# app/templates/storefront/account/dashboard.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
{% from 'shared/macros/modals.html' import confirm_modal %}
|
||||
|
||||
{% block title %}My Account - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}accountDashboard(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Account</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Welcome back, {{ user.first_name }}!</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Orders Card -->
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<a href="{{ base_url }}shop/account/profile"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('user', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Profile</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Edit your information</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ user.email }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Addresses Card -->
|
||||
<a href="{{ base_url }}shop/account/addresses"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage addresses</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Messages Card -->
|
||||
<a href="{{ base_url }}shop/account/messages"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ unreadCount: 0 }"
|
||||
x-init="fetch('/api/v1/shop/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('chat-bubble-left', 'h-8 w-8')"></span>
|
||||
<span x-show="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-600 rounded-full"
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Messages</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Contact support</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="unreadCount > 0">
|
||||
<p class="text-sm text-primary font-medium" style="color: var(--color-primary)" x-text="unreadCount + ' unread message' + (unreadCount > 1 ? 's' : '')"></p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Account Summary -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Account Summary</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button @click="showLogoutModal = true"
|
||||
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Confirmation Modal -->
|
||||
{{ confirm_modal(
|
||||
id='logoutModal',
|
||||
title='Logout Confirmation',
|
||||
message="Are you sure you want to logout? You'll need to sign in again to access your account.",
|
||||
confirm_action='confirmLogout()',
|
||||
show_var='showLogoutModal',
|
||||
confirm_text='Logout',
|
||||
cancel_text='Cancel',
|
||||
variant='danger'
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function accountDashboard() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
showLogoutModal: false,
|
||||
|
||||
confirmLogout() {
|
||||
// Close modal
|
||||
this.showLogoutModal = false;
|
||||
|
||||
fetch('/api/v1/shop/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Clear localStorage token if any
|
||||
localStorage.removeItem('customer_token');
|
||||
|
||||
// Show success message
|
||||
this.showToast('Logged out successfully', 'success');
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 500);
|
||||
} else {
|
||||
console.error('Logout failed with status:', response.status);
|
||||
this.showToast('Logout failed', 'error');
|
||||
// Still redirect on failure (cookie might be deleted)
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Logout error:', error);
|
||||
this.showToast('Logout failed', 'error');
|
||||
// Redirect anyway
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
251
app/templates/storefront/account/forgot-password.html
Normal file
251
app/templates/storefront/account/forgot-password.html
Normal file
@@ -0,0 +1,251 @@
|
||||
{# app/templates/storefront/account/forgot-password.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Forgot Password - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🔐</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Reset your password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Forgot Password Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<!-- Initial Form State -->
|
||||
<template x-if="!emailSent">
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Forgot Password
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
|
||||
<input x-model="email"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Send Reset Link</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Sending...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template x-if="emailSent">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
|
||||
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Check Your Email
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
We've sent a password reset link to <strong x-text="email"></strong>.
|
||||
Please check your inbox and click the link to reset your password.
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Didn't receive the email? Check your spam folder or
|
||||
<button @click="emailSent = false"
|
||||
class="font-medium hover:underline"
|
||||
style="color: var(--color-primary);">
|
||||
try again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Forgot Password Logic -->
|
||||
<script>
|
||||
function forgotPassword() {
|
||||
return {
|
||||
// Data
|
||||
email: '',
|
||||
emailSent: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle form submission
|
||||
async handleSubmit() {
|
||||
this.clearErrors();
|
||||
|
||||
// Basic validation
|
||||
if (!this.email) {
|
||||
this.errors.email = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) {
|
||||
this.errors.email = 'Please enter a valid email address';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: this.email
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Failed to send reset link');
|
||||
}
|
||||
|
||||
// Success - show email sent message
|
||||
this.emailSent = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
this.showAlert(error.message || 'Failed to send reset link. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
281
app/templates/storefront/account/login.html
Normal file
281
app/templates/storefront/account/login.html
Normal file
@@ -0,0 +1,281 @@
|
||||
{# app/templates/storefront/account/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Customer Login - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🛒</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Welcome back to your shopping experience</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Login Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Customer Login
|
||||
</h1>
|
||||
|
||||
<!-- Success Message (after registration) -->
|
||||
<div x-show="alert.show && alert.type === 'success'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
|
||||
<input x-model="credentials.email"
|
||||
:disabled="loading"
|
||||
@input="clearAllErrors"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<div class="relative">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearAllErrors"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox focus-primary focus:outline-none"
|
||||
style="color: var(--color-primary);">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
|
||||
</label>
|
||||
<a href="{{ base_url }}shop/account/forgot-password"
|
||||
class="text-sm font-medium hover:underline"
|
||||
style="color: var(--color-primary);">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/register">
|
||||
Create an account
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Login Logic -->
|
||||
<script>
|
||||
function customerLogin() {
|
||||
return {
|
||||
// Data
|
||||
credentials: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
rememberMe: false,
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
this.checkRegistrationSuccess();
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Check if redirected after registration
|
||||
checkRegistrationSuccess() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('registered') === 'true') {
|
||||
this.showAlert('Account created successfully! Please sign in.', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle login
|
||||
async handleLogin() {
|
||||
this.clearAllErrors();
|
||||
|
||||
// Basic validation
|
||||
if (!this.credentials.email) {
|
||||
this.errors.email = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.credentials.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email_or_username: this.credentials.email,
|
||||
password: this.credentials.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Login failed');
|
||||
}
|
||||
|
||||
// Store token and user data
|
||||
localStorage.setItem('customer_token', data.access_token);
|
||||
localStorage.setItem('customer_user', JSON.stringify(data.user));
|
||||
|
||||
this.showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect to account page or return URL
|
||||
setTimeout(() => {
|
||||
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}shop/account';
|
||||
window.location.href = returnUrl;
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.showAlert(error.message || 'Invalid email or password');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
520
app/templates/storefront/account/messages.html
Normal file
520
app/templates/storefront/account/messages.html
Normal file
@@ -0,0 +1,520 @@
|
||||
{# app/templates/storefront/account/messages.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Messages - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopMessages(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<nav class="flex mb-4" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||
<li class="inline-flex items-center">
|
||||
<a href="{{ base_url }}shop/account/dashboard"
|
||||
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary dark:text-gray-400 dark:hover:text-white"
|
||||
style="--hover-color: var(--color-primary)">
|
||||
<span class="w-4 h-4 mr-2" x-html="$icon('home', 'w-4 h-4')"></span>
|
||||
My Account
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<span class="w-6 h-6 text-gray-400" x-html="$icon('chevron-right', 'w-6 h-6')"></span>
|
||||
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">Messages</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Messages</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">View your conversations with the shop</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('spinner', 'h-8 w-8 animate-spin')"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Main Content -->
|
||||
<template x-if="!loading">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<!-- Conversation List View -->
|
||||
<template x-if="!selectedConversation">
|
||||
<div>
|
||||
<!-- Filter Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<div class="flex space-x-4">
|
||||
<button @click="statusFilter = null; loadConversations()"
|
||||
:class="statusFilter === null ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
|
||||
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
style="--color: var(--color-primary)">
|
||||
All
|
||||
</button>
|
||||
<button @click="statusFilter = 'open'; loadConversations()"
|
||||
:class="statusFilter === 'open' ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
|
||||
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
style="--color: var(--color-primary)">
|
||||
Open
|
||||
</button>
|
||||
<button @click="statusFilter = 'closed'; loadConversations()"
|
||||
:class="statusFilter === 'closed' ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
|
||||
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
style="--color: var(--color-primary)">
|
||||
Closed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-if="conversations.length === 0">
|
||||
<div class="px-6 py-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('chat-bubble-left', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No messages</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
You don't have any conversations yet.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="conv in conversations" :key="conv.id">
|
||||
<div @click="selectConversation(conv.id)"
|
||||
class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate"
|
||||
:class="conv.unread_count > 0 ? 'font-bold' : ''"
|
||||
x-text="conv.subject"></h3>
|
||||
<template x-if="conv.unread_count > 0">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary text-white"
|
||||
style="background-color: var(--color-primary)"
|
||||
x-text="conv.unread_count"></span>
|
||||
</template>
|
||||
<template x-if="conv.is_closed">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Closed
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="conv.other_participant_name"></p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-4 text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="formatDate(conv.last_message_at)"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="conv.message_count + ' messages'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{# noqa: FE-001 - Custom pagination with currentPage/totalPages vars (not pagination.page/pagination.total) #}
|
||||
<template x-if="totalPages > 1">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<button @click="prevPage()" :disabled="currentPage === 1"
|
||||
class="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</span>
|
||||
<button @click="nextPage()" :disabled="currentPage === totalPages"
|
||||
class="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Conversation Detail View -->
|
||||
<template x-if="selectedConversation">
|
||||
<div class="flex flex-col h-[600px]">
|
||||
<!-- Conversation Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="backToList()" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<span class="w-5 h-5" x-html="$icon('chevron-left', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedConversation.subject"></h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedConversation.other_participant_name"></p>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="selectedConversation.is_closed">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Closed
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4" x-ref="messagesContainer">
|
||||
<template x-for="msg in selectedConversation.messages" :key="msg.id">
|
||||
<div :class="msg.sender_type === 'customer' ? 'flex justify-end' : 'flex justify-start'">
|
||||
<div :class="msg.sender_type === 'customer' ? 'bg-primary text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'"
|
||||
class="max-w-xs lg:max-w-md px-4 py-2 rounded-lg"
|
||||
:style="msg.sender_type === 'customer' ? 'background-color: var(--color-primary)' : ''">
|
||||
<!-- System message styling -->
|
||||
<template x-if="msg.is_system_message">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400 italic text-sm" x-text="msg.content"></div>
|
||||
</template>
|
||||
<template x-if="!msg.is_system_message">
|
||||
<div>
|
||||
<p class="text-sm whitespace-pre-wrap" x-text="msg.content"></p>
|
||||
<!-- Attachments -->
|
||||
<template x-if="msg.attachments && msg.attachments.length > 0">
|
||||
<div class="mt-2 space-y-1">
|
||||
<template x-for="att in msg.attachments" :key="att.id">
|
||||
<a :href="att.download_url"
|
||||
target="_blank"
|
||||
class="flex items-center space-x-2 text-xs underline opacity-80 hover:opacity-100">
|
||||
<span class="w-4 h-4" x-html="$icon('paperclip', 'w-4 h-4')"></span>
|
||||
<span x-text="att.filename"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-xs mt-1 opacity-70" x-text="formatTime(msg.created_at)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
<template x-if="!selectedConversation.is_closed">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<form @submit.prevent="sendReply()">
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-1">
|
||||
<textarea x-model="replyContent"
|
||||
placeholder="Type your message..."
|
||||
rows="2"
|
||||
class="w-full px-3 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 resize-none"
|
||||
style="--ring-color: var(--color-primary)"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col justify-end space-y-2">
|
||||
<!-- File upload -->
|
||||
<label class="cursor-pointer text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<span class="w-6 h-6" x-html="$icon('paperclip', 'w-6 h-6')"></span>
|
||||
<input type="file" multiple class="hidden" @change="handleFileSelect($event)">
|
||||
</label>
|
||||
<button type="submit"
|
||||
:disabled="!replyContent.trim() && attachments.length === 0 || sending"
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="!sending">Send</span>
|
||||
<span x-show="sending" class="flex items-center">
|
||||
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
Sending
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected files preview -->
|
||||
<template x-if="attachments.length > 0">
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<template x-for="(file, index) in attachments" :key="index">
|
||||
<div class="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
|
||||
<span x-text="file.name" class="max-w-[150px] truncate"></span>
|
||||
<button type="button" @click="removeAttachment(index)" class="text-gray-500 hover:text-red-500">
|
||||
<span class="w-4 h-4" x-html="$icon('x-mark', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Closed conversation notice -->
|
||||
<template x-if="selectedConversation.is_closed">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
||||
This conversation is closed and cannot receive new messages.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopMessages() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
loading: true,
|
||||
conversations: [],
|
||||
selectedConversation: null,
|
||||
statusFilter: null,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
limit: 20,
|
||||
replyContent: '',
|
||||
attachments: [],
|
||||
sending: false,
|
||||
pollInterval: null,
|
||||
|
||||
// Preloaded conversation ID from URL
|
||||
preloadConversationId: {{ conversation_id|default('null') }},
|
||||
|
||||
async init() {
|
||||
await this.loadConversations();
|
||||
|
||||
// If conversation ID provided in URL, load it
|
||||
if (this.preloadConversationId) {
|
||||
await this.selectConversation(this.preloadConversationId);
|
||||
}
|
||||
|
||||
// Start polling for new messages
|
||||
this.pollInterval = setInterval(() => {
|
||||
if (this.selectedConversation) {
|
||||
this.refreshConversation();
|
||||
} else {
|
||||
this.loadConversations();
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
async loadConversations() {
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.currentPage - 1) * this.limit,
|
||||
limit: this.limit,
|
||||
});
|
||||
if (this.statusFilter) {
|
||||
params.append('status', this.statusFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load conversations');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.conversations = data.conversations;
|
||||
this.total = data.total;
|
||||
this.totalPages = Math.ceil(data.total / this.limit);
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
this.showToast('Failed to load messages', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async selectConversation(conversationId) {
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to load conversation');
|
||||
|
||||
this.selectedConversation = await response.json();
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL without reload
|
||||
const url = `{{ base_url }}shop/account/messages/${conversationId}`;
|
||||
history.pushState({}, '', url);
|
||||
} catch (error) {
|
||||
console.error('Error loading conversation:', error);
|
||||
this.showToast('Failed to load conversation', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async refreshConversation() {
|
||||
if (!this.selectedConversation) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const oldCount = this.selectedConversation.messages.length;
|
||||
this.selectedConversation = data;
|
||||
|
||||
// Scroll if new messages
|
||||
if (data.messages.length > oldCount) {
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing conversation:', error);
|
||||
}
|
||||
},
|
||||
|
||||
backToList() {
|
||||
this.selectedConversation = null;
|
||||
this.replyContent = '';
|
||||
this.attachments = [];
|
||||
this.loadConversations();
|
||||
|
||||
// Update URL
|
||||
history.pushState({}, '', '{{ base_url }}shop/account/messages');
|
||||
},
|
||||
|
||||
async sendReply() {
|
||||
if ((!this.replyContent.trim() && this.attachments.length === 0) || this.sending) return;
|
||||
|
||||
this.sending = true;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', this.replyContent);
|
||||
for (const file of this.attachments) {
|
||||
formData.append('attachments', file);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to send message');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add message to list
|
||||
this.selectedConversation.messages.push(data.message);
|
||||
|
||||
// Clear form
|
||||
this.replyContent = '';
|
||||
this.attachments = [];
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
this.showToast('Message sent', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
this.showToast(error.message || 'Failed to send message', 'error');
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.attachments.push(...files);
|
||||
event.target.value = ''; // Reset input
|
||||
},
|
||||
|
||||
removeAttachment(index) {
|
||||
this.attachments.splice(index, 1);
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.loadConversations();
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
this.loadConversations();
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
// Less than 24 hours
|
||||
if (diff < 86400000) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// Less than 7 days
|
||||
if (diff < 604800000) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
559
app/templates/storefront/account/order-detail.html
Normal file
559
app/templates/storefront/account/order-detail.html
Normal file
@@ -0,0 +1,559 @@
|
||||
{# app/templates/storefront/account/order-detail.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Order Details - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopOrderDetailPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<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/account/dashboard" class="hover:text-primary">My Account</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<a href="{{ base_url }}shop/account/orders" class="hover:text-primary">Orders</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" x-text="order?.order_number || 'Order Details'"></span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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-6">
|
||||
<div class="flex">
|
||||
<span class="h-6 w-6 text-red-400" x-html="$icon('x-circle', 'h-6 w-6')"></span>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Content -->
|
||||
<div x-show="!loading && !error && order" x-cloak>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" x-text="'Order ' + order.order_number"></h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Placed on <span x-text="formatDateTime(order.order_date || order.created_at)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
:class="getStatusClass(order.status)">
|
||||
<span x-text="getStatusLabel(order.status)"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Order Tracking Timeline -->
|
||||
<div class="mb-8 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-6">Order Progress</h2>
|
||||
<div class="relative">
|
||||
<!-- Timeline Line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
<!-- Timeline Steps -->
|
||||
<div class="space-y-6">
|
||||
<!-- Pending -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('pending')">
|
||||
<span class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Order Placed</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDateTime(order.order_date || order.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('processing')">
|
||||
<span x-show="isStepComplete('processing')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('processing')" class="w-4 h-4" x-html="$icon('clock', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('processing') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Processing</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.confirmed_at" x-text="formatDateTime(order.confirmed_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipped -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('shipped')">
|
||||
<span x-show="isStepComplete('shipped')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('shipped')" class="w-4 h-4" x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('shipped') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Shipped</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.shipped_at" x-text="formatDateTime(order.shipped_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivered -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('delivered')">
|
||||
<span x-show="isStepComplete('delivered')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('delivered')" class="w-4 h-4" x-html="$icon('gift', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('delivered') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Delivered</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.delivered_at" x-text="formatDateTime(order.delivered_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled/Refunded Notice -->
|
||||
<div x-show="order.status === 'cancelled' || order.status === 'refunded'"
|
||||
class="mt-6 p-4 rounded-lg"
|
||||
:class="order.status === 'cancelled' ? 'bg-red-50 dark:bg-red-900/20' : 'bg-gray-50 dark:bg-gray-700'">
|
||||
<p class="text-sm font-medium"
|
||||
:class="order.status === 'cancelled' ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'"
|
||||
x-text="order.status === 'cancelled' ? 'This order was cancelled' : 'This order was refunded'"></p>
|
||||
<p class="text-sm mt-1"
|
||||
:class="order.status === 'cancelled' ? 'text-red-600 dark:text-red-300' : 'text-gray-600 dark:text-gray-400'"
|
||||
x-show="order.cancelled_at"
|
||||
x-text="'on ' + formatDateTime(order.cancelled_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Content (Left Column - 2/3) -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Order Items</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="item in order.items" :key="item.id">
|
||||
<div class="px-6 py-4 flex items-center gap-4">
|
||||
<!-- Product Image Placeholder -->
|
||||
<div class="flex-shrink-0 w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span class="h-8 w-8 text-gray-400" x-html="$icon('cube', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<!-- Product Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="item.product_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
SKU: <span x-text="item.product_sku || '-'"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Qty: <span x-text="item.quantity"></span>
|
||||
×
|
||||
<span x-text="formatPrice(item.unit_price)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Line Total -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addresses -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Shipping Address -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('location-marker', 'h-5 w-5')"></span>
|
||||
Shipping Address
|
||||
</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<p class="font-medium" x-text="(order.ship_first_name || '') + ' ' + (order.ship_last_name || '')"></p>
|
||||
<p x-show="order.ship_company" x-text="order.ship_company"></p>
|
||||
<p x-text="order.ship_address_line_1"></p>
|
||||
<p x-show="order.ship_address_line_2" x-text="order.ship_address_line_2"></p>
|
||||
<p x-text="(order.ship_postal_code || '') + ' ' + (order.ship_city || '')"></p>
|
||||
<p x-text="order.ship_country_iso"></p>
|
||||
</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">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('document-text', 'h-5 w-5')"></span>
|
||||
Billing Address
|
||||
</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<p class="font-medium" x-text="(order.bill_first_name || '') + ' ' + (order.bill_last_name || '')"></p>
|
||||
<p x-show="order.bill_company" x-text="order.bill_company"></p>
|
||||
<p x-text="order.bill_address_line_1"></p>
|
||||
<p x-show="order.bill_address_line_2" x-text="order.bill_address_line_2"></p>
|
||||
<p x-text="(order.bill_postal_code || '') + ' ' + (order.bill_city || '')"></p>
|
||||
<p x-text="order.bill_country_iso"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Notes -->
|
||||
<div x-show="order.customer_notes" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('chat-bubble-left', 'h-5 w-5')"></span>
|
||||
Order Notes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300" x-text="order.customer_notes"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (Right Column - 1/3) -->
|
||||
<div class="space-y-6">
|
||||
<!-- Order Summary -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Summary</h3>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Subtotal</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.subtotal)"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Shipping</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.shipping_amount)"></dd>
|
||||
</div>
|
||||
<!-- VAT Breakdown -->
|
||||
<div x-show="order.tax_amount > 0 || order.vat_rate_label" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">
|
||||
<span x-text="order.vat_rate_label || 'Tax'"></span>
|
||||
<span x-show="order.vat_rate" class="text-xs ml-1">(<span x-text="order.vat_rate"></span>%)</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.tax_amount)"></dd>
|
||||
</div>
|
||||
<!-- VAT Regime Info (for special cases) -->
|
||||
<div x-show="order.vat_regime === 'reverse_charge'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Reverse Charge applies (B2B transaction)
|
||||
</div>
|
||||
<div x-show="order.vat_regime === 'exempt'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Exempt (Non-EU destination)
|
||||
</div>
|
||||
<div x-show="order.discount_amount > 0" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Discount</dt>
|
||||
<dd class="font-medium text-green-600 dark:text-green-400" x-text="'-' + formatPrice(order.discount_amount)"></dd>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 flex justify-between">
|
||||
<dt class="text-base font-semibold text-gray-900 dark:text-white">Total</dt>
|
||||
<dd class="text-base font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Download -->
|
||||
<div x-show="canDownloadInvoice()" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('document-text', 'h-5 w-5')"></span>
|
||||
Invoice
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Download your invoice for this order.
|
||||
</p>
|
||||
<button @click="downloadInvoice()"
|
||||
:disabled="downloadingInvoice"
|
||||
class="w-full inline-flex justify-center items-center 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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="!downloadingInvoice" class="h-4 w-4 mr-2" x-html="$icon('arrow-down-tray', 'h-4 w-4')"></span>
|
||||
<span x-show="downloadingInvoice" class="h-4 w-4 mr-2" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="downloadingInvoice ? 'Generating...' : 'Download Invoice'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Info -->
|
||||
<div x-show="order.shipping_method || order.tracking_number" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('archive', 'h-5 w-5')"></span>
|
||||
Shipping
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div x-show="order.shipping_method">
|
||||
<p class="text-gray-500 dark:text-gray-400">Method</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_method"></p>
|
||||
</div>
|
||||
<div x-show="order.shipping_carrier">
|
||||
<p class="text-gray-500 dark:text-gray-400">Carrier</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_carrier"></p>
|
||||
</div>
|
||||
<div x-show="order.tracking_number">
|
||||
<p class="text-gray-500 dark:text-gray-400">Tracking Number</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.tracking_number"></p>
|
||||
</div>
|
||||
<!-- Track Package Button -->
|
||||
<a x-show="order.tracking_url"
|
||||
:href="order.tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('location-marker', 'h-4 w-4')"></span>
|
||||
Track Package
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Need Help? -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Need Help?</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
If you have any questions about your order, please contact us.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/account/messages"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('chat-bubble-left', 'h-4 w-4')"></span>
|
||||
Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-8">
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-primary">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('chevron-left', 'h-4 w-4')"></span>
|
||||
Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopOrderDetailPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
order: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
orderId: {{ order_id }},
|
||||
downloadingInvoice: false,
|
||||
|
||||
// Status mapping
|
||||
statuses: {
|
||||
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
|
||||
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
|
||||
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
},
|
||||
|
||||
// Timeline step order for progress tracking
|
||||
timelineSteps: ['pending', 'processing', 'shipped', 'delivered'],
|
||||
|
||||
async init() {
|
||||
await this.loadOrder();
|
||||
},
|
||||
|
||||
async loadOrder() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Order not found');
|
||||
}
|
||||
throw new Error('Failed to load order details');
|
||||
}
|
||||
|
||||
this.order = await response.json();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading order:', err);
|
||||
this.error = err.message || 'Failed to load order';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
return this.statuses[status]?.label || status;
|
||||
},
|
||||
|
||||
getStatusClass(status) {
|
||||
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// ===== Timeline Functions =====
|
||||
|
||||
/**
|
||||
* Get the current step index in the order flow
|
||||
*/
|
||||
getCurrentStepIndex() {
|
||||
if (!this.order) return 0;
|
||||
const status = this.order.status;
|
||||
|
||||
// Handle special statuses
|
||||
if (status === 'cancelled' || status === 'refunded') {
|
||||
return -1; // Special case
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return 4; // All steps complete
|
||||
}
|
||||
if (status === 'partially_shipped') {
|
||||
return 2; // Between processing and shipped
|
||||
}
|
||||
|
||||
return this.timelineSteps.indexOf(status) + 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a timeline step is complete
|
||||
*/
|
||||
isStepComplete(step) {
|
||||
if (!this.order) return false;
|
||||
|
||||
const currentIndex = this.getCurrentStepIndex();
|
||||
const stepIndex = this.timelineSteps.indexOf(step) + 1;
|
||||
|
||||
return currentIndex >= stepIndex;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get CSS classes for a timeline step
|
||||
*/
|
||||
getTimelineStepClass(step) {
|
||||
if (this.isStepComplete(step)) {
|
||||
// Completed step - green
|
||||
return 'bg-green-500 text-white';
|
||||
} else if (this.order && this.timelineSteps.indexOf(this.order.status) === this.timelineSteps.indexOf(step)) {
|
||||
// Current step - primary color with pulse
|
||||
return 'bg-blue-500 text-white animate-pulse';
|
||||
} else {
|
||||
// Future step - gray
|
||||
return 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Invoice Functions =====
|
||||
|
||||
/**
|
||||
* Check if invoice can be downloaded (order must be at least processing)
|
||||
*/
|
||||
canDownloadInvoice() {
|
||||
if (!this.order) return false;
|
||||
const invoiceStatuses = ['processing', 'partially_shipped', 'shipped', 'delivered', 'completed'];
|
||||
return invoiceStatuses.includes(this.order.status);
|
||||
},
|
||||
|
||||
/**
|
||||
* Download invoice PDF for this order
|
||||
*/
|
||||
async downloadInvoice() {
|
||||
if (this.downloadingInvoice) return;
|
||||
|
||||
this.downloadingInvoice = true;
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}/invoice`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Invoice not yet available. Please try again later.');
|
||||
}
|
||||
throw new Error('Failed to download invoice');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header if available
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `invoice-${this.order.order_number}.pdf`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
filename = match[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Download the blob
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error downloading invoice:', err);
|
||||
alert(err.message || 'Failed to download invoice');
|
||||
} finally {
|
||||
this.downloadingInvoice = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
225
app/templates/storefront/account/orders.html
Normal file
225
app/templates/storefront/account/orders.html
Normal file
@@ -0,0 +1,225 @@
|
||||
{# app/templates/storefront/account/orders.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Order History - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopOrdersPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<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/account/dashboard" class="hover:text-primary">My Account</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">Order History</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Order History</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">View and track your orders</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !error && orders.length === 0"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No orders yet</h3>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">Start shopping to see your orders here.</p>
|
||||
<a href="{{ base_url }}shop/products"
|
||||
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 transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div x-show="!loading && !error && orders.length > 0" class="space-y-4">
|
||||
<template x-for="order in orders" :key="order.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Order Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap items-center gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Order Number</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="order.order_number"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Date</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatDate(order.order_date || order.created_at)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
<p class="text-sm font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Status Badge -->
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(order.status)">
|
||||
<span x-text="getStatusLabel(order.status)"></span>
|
||||
</span>
|
||||
<!-- View Details Button -->
|
||||
<a :href="'{{ base_url }}shop/account/orders/' + order.id"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md 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 transition-colors">
|
||||
View Details
|
||||
<span class="ml-2 h-4 w-4" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items Preview -->
|
||||
<div class="px-6 py-4">
|
||||
<template x-for="item in order.items.slice(0, 3)" :key="item.id">
|
||||
<div class="flex items-center py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.product_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Qty: <span x-text="item.quantity"></span> ×
|
||||
<span x-text="formatPrice(item.unit_price)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="ml-4 text-sm font-medium text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="order.items.length > 3"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 mt-2"
|
||||
x-text="'+ ' + (order.items.length - 3) + ' more item(s)'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="totalPages > 1" class="flex justify-center mt-8">
|
||||
<nav class="flex items-center space-x-2">
|
||||
<button @click="loadOrders(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 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 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</span>
|
||||
<button @click="loadOrders(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 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 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopOrdersPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
orders: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
perPage: 10,
|
||||
|
||||
// Status mapping
|
||||
statuses: {
|
||||
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
|
||||
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
|
||||
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadOrders(1);
|
||||
},
|
||||
|
||||
async loadOrders(page = 1) {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * this.perPage;
|
||||
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load orders');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.orders = data.orders || [];
|
||||
this.currentPage = page;
|
||||
this.totalPages = Math.ceil((data.total || 0) / this.perPage);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading orders:', err);
|
||||
this.error = err.message || 'Failed to load orders';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
return this.statuses[status]?.label || status;
|
||||
},
|
||||
|
||||
getStatusClass(status) {
|
||||
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
524
app/templates/storefront/account/profile.html
Normal file
524
app/templates/storefront/account/profile.html
Normal file
@@ -0,0 +1,524 @@
|
||||
{# app/templates/storefront/account/profile.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}My Profile - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopProfilePage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<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/account/dashboard" class="hover:text-primary">My Account</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">Profile</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Profile</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account information and preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<span class="h-5 w-5 text-green-400" x-html="$icon('check-circle', 'h-5 w-5')"></span>
|
||||
<p class="ml-3 text-sm text-green-700 dark:text-green-300" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading" class="space-y-8">
|
||||
<!-- Profile Information Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your personal details</p>
|
||||
</div>
|
||||
<form @submit.prevent="saveProfile" class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<!-- First Name -->
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="first_name" x-model="profileForm.first_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="last_name" x-model="profileForm.last_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" id="email" x-model="profileForm.email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<input type="tel" id="phone" x-model="profileForm.phone"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingProfile"
|
||||
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
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="savingProfile" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="savingProfile ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Preferences</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your account preferences</p>
|
||||
</div>
|
||||
<form @submit.prevent="savePreferences" class="p-6 space-y-6">
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preferred Language
|
||||
</label>
|
||||
<select id="language" x-model="preferencesForm.preferred_language"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<option value="">Use shop default</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Francais</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="lb">Letzebuergesch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="checkbox" id="marketing_consent" x-model="preferencesForm.marketing_consent"
|
||||
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600
|
||||
focus:ring-2 focus:ring-primary
|
||||
dark:bg-gray-700"
|
||||
style="color: var(--color-primary)">
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="marketing_consent" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Marketing Communications
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Receive emails about new products, offers, and promotions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingPreferences"
|
||||
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
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="savingPreferences" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="savingPreferences ? 'Saving...' : 'Save Preferences'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your account password</p>
|
||||
</div>
|
||||
<form @submit.prevent="changePassword" class="p-6 space-y-6">
|
||||
<!-- Current Password -->
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="current_password" x-model="passwordForm.current_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="new_password" x-model="passwordForm.new_password" required
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 8 characters with at least one letter and one number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p x-show="passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="mt-1 text-xs text-red-500">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Error -->
|
||||
<div x-show="passwordError" class="text-sm text-red-600 dark:text-red-400" x-text="passwordError"></div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="changingPassword || passwordForm.new_password !== passwordForm.confirm_password"
|
||||
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
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="changingPassword" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="changingPassword ? 'Changing...' : 'Change Password'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (read-only) -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Account Information</h3>
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Customer Number</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.customer_number || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Member Since</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatDate(profile?.created_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Orders</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.total_orders || 0"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Spent</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatPrice(profile?.total_spent)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopProfilePage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
profile: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
successMessage: '',
|
||||
|
||||
// Forms
|
||||
profileForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
},
|
||||
preferencesForm: {
|
||||
preferred_language: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
passwordForm: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
},
|
||||
|
||||
// Form states
|
||||
savingProfile: false,
|
||||
savingPreferences: false,
|
||||
changingPassword: false,
|
||||
passwordError: '',
|
||||
|
||||
async init() {
|
||||
await this.loadProfile();
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
|
||||
// Populate forms
|
||||
this.profileForm = {
|
||||
first_name: this.profile.first_name || '',
|
||||
last_name: this.profile.last_name || '',
|
||||
email: this.profile.email || '',
|
||||
phone: this.profile.phone || ''
|
||||
};
|
||||
this.preferencesForm = {
|
||||
preferred_language: this.profile.preferred_language || '',
|
||||
marketing_consent: this.profile.marketing_consent || false
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
this.error = err.message || 'Failed to load profile';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.profileForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Profile updated successfully';
|
||||
|
||||
// Update localStorage user data
|
||||
const userStr = localStorage.getItem('customer_user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
user.first_name = this.profile.first_name;
|
||||
user.last_name = this.profile.last_name;
|
||||
user.email = this.profile.email;
|
||||
localStorage.setItem('customer_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving profile:', err);
|
||||
this.error = err.message || 'Failed to save profile';
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences() {
|
||||
this.savingPreferences = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.preferencesForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save preferences');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Preferences updated successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving preferences:', err);
|
||||
this.error = err.message || 'Failed to save preferences';
|
||||
} finally {
|
||||
this.savingPreferences = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.passwordError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword = true;
|
||||
this.passwordError = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.passwordForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to change password');
|
||||
}
|
||||
|
||||
// Clear password form
|
||||
this.passwordForm = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
};
|
||||
|
||||
this.successMessage = 'Password changed successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error changing password:', err);
|
||||
this.passwordError = err.message || 'Failed to change password';
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
377
app/templates/storefront/account/register.html
Normal file
377
app/templates/storefront/account/register.html
Normal file
@@ -0,0 +1,377 @@
|
||||
{# app/templates/storefront/account/register.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Account - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🛒</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Join our community today</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Registration Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Create Account
|
||||
</h1>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="alert.show && alert.type === 'success'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form @submit.prevent="handleRegister">
|
||||
<!-- First Name -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
First Name <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.first_name"
|
||||
:disabled="loading"
|
||||
@input="clearError('first_name')"
|
||||
type="text"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.first_name }"
|
||||
placeholder="Enter your first name"
|
||||
required />
|
||||
<span x-show="errors.first_name" x-text="errors.first_name"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Last Name -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Last Name <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.last_name"
|
||||
:disabled="loading"
|
||||
@input="clearError('last_name')"
|
||||
type="text"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.last_name }"
|
||||
placeholder="Enter your last name"
|
||||
required />
|
||||
<span x-show="errors.last_name" x-text="errors.last_name"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Email -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email Address <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.email"
|
||||
:disabled="loading"
|
||||
@input="clearError('email')"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Phone (Optional) -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Phone Number</span>
|
||||
<input x-model="formData.phone"
|
||||
:disabled="loading"
|
||||
type="tel"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
placeholder="+352 123 456 789" />
|
||||
</label>
|
||||
|
||||
<!-- Password -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Password <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<div class="relative">
|
||||
<input x-model="formData.password"
|
||||
:disabled="loading"
|
||||
@input="clearError('password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="At least 8 characters"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must contain at least 8 characters, one letter, and one number
|
||||
</p>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Confirm Password <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="confirmPassword"
|
||||
:disabled="loading"
|
||||
@input="clearError('confirmPassword')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.confirmPassword }"
|
||||
placeholder="Re-enter your password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="flex items-start mt-4">
|
||||
<input type="checkbox"
|
||||
x-model="formData.marketing_consent"
|
||||
id="marketingConsent"
|
||||
class="form-checkbox focus-primary focus:outline-none mt-1"
|
||||
style="color: var(--color-primary);">
|
||||
<label for="marketingConsent" class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
I'd like to receive news and special offers
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Create Account</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Creating account...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in instead
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Registration Logic -->
|
||||
<script>
|
||||
function customerRegistration() {
|
||||
return {
|
||||
// Data
|
||||
formData: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
confirmPassword: '',
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Clear specific error
|
||||
clearError(field) {
|
||||
delete this.errors[field];
|
||||
},
|
||||
|
||||
// Clear all errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Validate form
|
||||
validateForm() {
|
||||
this.clearAllErrors();
|
||||
let isValid = true;
|
||||
|
||||
// First name
|
||||
if (!this.formData.first_name.trim()) {
|
||||
this.errors.first_name = 'First name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Last name
|
||||
if (!this.formData.last_name.trim()) {
|
||||
this.errors.last_name = 'Last name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Email
|
||||
if (!this.formData.email.trim()) {
|
||||
this.errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
|
||||
this.errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password
|
||||
if (!this.formData.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (this.formData.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
} else if (!/[a-zA-Z]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one letter';
|
||||
isValid = false;
|
||||
} else if (!/[0-9]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one number';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Confirm password
|
||||
if (this.formData.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
// Handle registration
|
||||
async handleRegister() {
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Registration failed');
|
||||
}
|
||||
|
||||
// Success!
|
||||
this.showAlert('Account created successfully! Redirecting to login...', 'success');
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login?registered=true';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.showAlert(error.message || 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
314
app/templates/storefront/account/reset-password.html
Normal file
314
app/templates/storefront/account/reset-password.html
Normal file
@@ -0,0 +1,314 @@
|
||||
{# app/templates/storefront/account/reset-password.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="resetPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reset Password - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🔑</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Create new password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Reset Password Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<!-- Invalid Token State -->
|
||||
<template x-if="tokenInvalid">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900">
|
||||
<span class="w-8 h-8 text-red-600 dark:text-red-400" x-html="$icon('x-mark', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Invalid or Expired Link
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
Please request a new password reset link.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/forgot-password"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Request New Link
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Form State -->
|
||||
<template x-if="!tokenInvalid && !resetComplete">
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your new password below. Password must be at least 8 characters.
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Reset Password Form -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">New Password</span>
|
||||
<input x-model="password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="Enter new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Confirm Password</span>
|
||||
<input x-model="confirmPassword"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.confirmPassword }"
|
||||
placeholder="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Reset Password</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Resetting...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template x-if="resetComplete">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
|
||||
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Password Reset Complete
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your password has been successfully reset.
|
||||
You can now sign in with your new password.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/login"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Reset Password Logic -->
|
||||
<script>
|
||||
function resetPassword() {
|
||||
return {
|
||||
// Data
|
||||
token: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
tokenInvalid: false,
|
||||
resetComplete: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
|
||||
// Get token from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.token = urlParams.get('token');
|
||||
|
||||
if (!this.token) {
|
||||
this.tokenInvalid = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle form submission
|
||||
async handleSubmit() {
|
||||
this.clearErrors();
|
||||
|
||||
// Validation
|
||||
if (!this.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Please confirm your password';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reset_token: this.token,
|
||||
new_password: this.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for token-related errors
|
||||
if (response.status === 400 && data.detail) {
|
||||
if (data.detail.includes('invalid') || data.detail.includes('expired')) {
|
||||
this.tokenInvalid = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(data.detail || 'Failed to reset password');
|
||||
}
|
||||
|
||||
// Success
|
||||
this.resetComplete = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
this.showAlert(error.message || 'Failed to reset password. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user