refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -1,549 +0,0 @@
{# 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">&#8203;</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">&#8203;</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 %}

View File

@@ -1,183 +0,0 @@
{# 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 %}

View File

@@ -1,251 +0,0 @@
{# 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>

View File

@@ -1,281 +0,0 @@
{# 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>

View File

@@ -1,520 +0,0 @@
{# 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 %}

View File

@@ -1,559 +0,0 @@
{# 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>
&times;
<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 %}

View File

@@ -1,225 +0,0 @@
{# 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> &times;
<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 %}

View File

@@ -1,524 +0,0 @@
{# 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 %}

View File

@@ -1,377 +0,0 @@
{# 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>

View File

@@ -1,314 +0,0 @@
{# 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>

View File

@@ -1,319 +0,0 @@
{# app/templates/storefront/cart.html #}
{% extends "storefront/base.html" %}
{% block title %}Shopping Cart{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shoppingCart(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Cart</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
Shopping Cart
</h1>
</div>
{# Loading State #}
<div x-show="loading && items.length === 0" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Empty Cart #}
<div x-show="!loading && items.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">🛒</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Your cart is empty
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Add some products to get started!
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
Browse Products
</a>
</div>
{# Cart Items #}
<div x-show="items.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# Cart Items List #}
<div class="lg:col-span-2 space-y-4">
<template x-for="item in items" :key="item.product_id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex gap-6">
{# Item Image #}
<div class="flex-shrink-0">
<img
:src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="item.name"
class="w-24 h-24 object-cover rounded-lg"
>
</div>
{# Item Details #}
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-1" x-text="item.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2" x-text="'SKU: ' + item.sku"></p>
<p class="text-xl font-bold text-primary mb-4">
<span x-text="parseFloat(item.price).toFixed(2)"></span>
</p>
{# Quantity Controls #}
{# noqa: FE-008 - Custom quantity stepper with async updateQuantity() per-item and :value binding #}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<button
@click="updateQuantity(item.product_id, item.quantity - 1)"
:disabled="item.quantity <= 1 || updating"
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<input
type="number"
:value="item.quantity"
@change="updateQuantity(item.product_id, $event.target.value)"
min="1"
max="99"
:disabled="updating"
class="w-16 text-center px-2 py-1 border-2 border-gray-300 dark:border-gray-600 rounded font-semibold dark:bg-gray-700 dark:text-white"
>
<button
@click="updateQuantity(item.product_id, item.quantity + 1)"
:disabled="updating"
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
+
</button>
</div>
<div class="flex-1 text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">Subtotal</p>
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">
<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
</p>
</div>
<button
@click="removeItem(item.product_id)"
:disabled="updating"
class="w-10 h-10 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-red-600 hover:border-red-600 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Remove from cart"
>
🗑️
</button>
</div>
</div>
</div>
</div>
</template>
</div>
{# Cart Summary #}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 sticky top-4">
<h3 class="text-xl font-semibold mb-6 pb-4 border-b-2 border-gray-200 dark:border-gray-700">
Order Summary
</h3>
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Subtotal (<span x-text="totalItems"></span> items):</span>
<span class="font-semibold"><span x-text="subtotal.toFixed(2)"></span></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Shipping:</span>
<span class="font-semibold" x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
</div>
<div class="flex justify-between text-lg font-bold pt-3 border-t-2 border-gray-200 dark:border-gray-700">
<span>Total:</span>
<span class="text-primary text-2xl"><span x-text="total.toFixed(2)"></span></span>
</div>
</div>
<button
@click="proceedToCheckout()"
:disabled="updating || items.length === 0"
class="w-full px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-primary-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-3"
>
Proceed to Checkout
</button>
<a href="{{ base_url }}shop/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Continue Shopping
</a>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Free shipping on orders over €50
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shoppingCart', () => {
const baseData = shopLayoutData();
return {
...baseData,
items: [],
loading: false,
updating: false,
// Computed properties
get totalItems() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
get subtotal() {
return this.items.reduce((sum, item) =>
sum + (parseFloat(item.price) * item.quantity), 0
);
},
get shipping() {
// Free shipping over €50
return this.subtotal >= 50 ? 0 : 5.99;
},
get total() {
return this.subtotal + this.shipping;
},
// Initialize
async init() {
console.log('[SHOP] Cart page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
await this.loadCart();
},
// Load cart from API
async loadCart() {
this.loading = true;
try {
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.items = data.items || [];
this.cartCount = this.totalItems;
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
}
} catch (error) {
console.error('[SHOP] Failed to load cart:', error);
this.showToast('Failed to load cart', 'error');
} finally {
this.loading = false;
}
},
// Update item quantity
async updateQuantity(productId, newQuantity) {
newQuantity = parseInt(newQuantity);
if (newQuantity < 1 || newQuantity > 99) return;
this.updating = true;
try {
console.log('[SHOP] Updating quantity:', productId, newQuantity);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: newQuantity })
}
);
if (response.ok) {
await this.loadCart();
this.showToast('Quantity updated', 'success');
} else {
throw new Error('Failed to update quantity');
}
} catch (error) {
console.error('[SHOP] Update quantity error:', error);
this.showToast('Failed to update quantity', 'error');
} finally {
this.updating = false;
}
},
// Remove item from cart
async removeItem(productId) {
if (!confirm('Remove this item from your cart?')) {
return;
}
this.updating = true;
try {
console.log('[SHOP] Removing item:', productId);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
{
method: 'DELETE'
}
);
if (response.ok) {
await this.loadCart();
this.showToast('Item removed from cart', 'success');
} else {
throw new Error('Failed to remove item');
}
} catch (error) {
console.error('[SHOP] Remove item error:', error);
this.showToast('Failed to remove item', 'error');
} finally {
this.updating = false;
}
},
// Proceed to checkout
proceedToCheckout() {
// Check if customer is logged in
const token = localStorage.getItem('customer_token');
if (!token) {
// Redirect to login with return URL
window.location.href = '{{ base_url }}shop/account/login?return={{ base_url }}shop/checkout';
} else {
window.location.href = '{{ base_url }}shop/checkout';
}
}
};
});
});
</script>
{% endblock %}

View File

@@ -1,915 +0,0 @@
{# app/templates/storefront/checkout.html #}
{% extends "storefront/base.html" %}
{% block title %}Checkout - {{ vendor.name }}{% endblock %}
{% block alpine_data %}checkoutPage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li><a href="{{ base_url }}shop/" class="hover:text-primary">Home</a></li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<span class="text-gray-900 dark:text-white">Checkout</span>
</li>
</ol>
</nav>
{# Page Header #}
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
</div>
{# Empty Cart #}
<div x-show="!loading && cartItems.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<span class="mx-auto h-12 w-12 text-gray-400 mb-4 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
{# Checkout Form #}
<div x-show="!loading && cartItems.length > 0" x-cloak>
<form @submit.prevent="placeOrder()" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# Left Column - Forms #}
<div class="lg:col-span-2 space-y-6">
{# Step Indicator #}
<div class="flex items-center justify-center mb-8">
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" style="background-color: var(--color-primary)">1</div>
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Information</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 2 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 2 ? 'background-color: var(--color-primary)' : ''">2</div>
<span class="ml-2 text-sm font-medium" :class="step >= 2 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Shipping</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 3 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 3 ? 'background-color: var(--color-primary)' : ''">3</div>
<span class="ml-2 text-sm font-medium" :class="step >= 3 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Review</span>
</div>
</div>
{# Error Message #}
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<span class="h-5 w-5 text-red-400" x-html="$icon('x-circle', 'h-5 w-5')"></span>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
{# Step 1: Contact & Shipping Address #}
<div x-show="step === 1" class="space-y-6">
{# Contact Information #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Contact Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="customer.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="customer.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email *</label>
<input type="email" x-model="customer.email" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input type="tel" x-model="customer.phone"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
</div>
</div>
{# Shipping Address #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Address</h2>
{# Saved Addresses Selector (only shown for logged in customers) #}
<div x-show="isLoggedIn && shippingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedShippingAddressId" @change="populateFromSavedAddress('shipping')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in shippingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="shippingAddress.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="shippingAddress.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="shippingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="shippingAddress.address_line_1" required placeholder="Street and number"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="shippingAddress.address_line_2" placeholder="Apartment, suite, etc."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="shippingAddress.postal_code" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="shippingAddress.city" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="shippingAddress.country_iso" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedShippingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveShippingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
{# Billing Address #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="sameAsShipping" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Same as shipping</span>
</label>
</div>
{# Saved Addresses Selector (only shown for logged in customers when not same as shipping) #}
<div x-show="isLoggedIn && !sameAsShipping && billingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedBillingAddressId" @change="populateFromSavedAddress('billing')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in billingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div x-show="!sameAsShipping" x-collapse class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="billingAddress.first_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="billingAddress.last_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="billingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="billingAddress.address_line_1" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="billingAddress.address_line_2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="billingAddress.postal_code" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="billingAddress.city" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="billingAddress.country_iso" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedBillingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveBillingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Continue to Shipping
</button>
</div>
</div>
{# Step 2: Shipping Method #}
<div x-show="step === 2" class="space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Method</h2>
<div class="space-y-3">
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'standard' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'standard' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="standard" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Standard Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">3-5 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white" x-text="subtotal >= 50 ? 'FREE' : '5.99'"></span>
</label>
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'express' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'express' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="express" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Express Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">1-2 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white">9.99</span>
</label>
</div>
</div>
{# Order Notes #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Notes (Optional)</h2>
<textarea x-model="customerNotes" rows="3" placeholder="Special instructions for your order..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(1)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="button" @click="goToStep(3)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Review Order
</button>
</div>
</div>
{# Step 3: Review & Place Order #}
<div x-show="step === 3" class="space-y-6">
{# Review Contact Info #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Contact Information</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="customer.first_name + ' ' + customer.last_name"></p>
<p class="text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
<p x-show="customer.phone" class="text-gray-600 dark:text-gray-400" x-text="customer.phone"></p>
</div>
{# Review Addresses #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<p class="font-medium text-gray-900 dark:text-white" x-text="shippingAddress.first_name + ' ' + shippingAddress.last_name"></p>
<p x-show="shippingAddress.company" x-text="shippingAddress.company"></p>
<p x-text="shippingAddress.address_line_1"></p>
<p x-show="shippingAddress.address_line_2" x-text="shippingAddress.address_line_2"></p>
<p x-text="shippingAddress.postal_code + ' ' + shippingAddress.city"></p>
<p x-text="getCountryName(shippingAddress.country_iso)"></p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<template x-if="sameAsShipping">
<p class="italic">Same as shipping address</p>
</template>
<template x-if="!sameAsShipping">
<div>
<p class="font-medium text-gray-900 dark:text-white" x-text="billingAddress.first_name + ' ' + billingAddress.last_name"></p>
<p x-show="billingAddress.company" x-text="billingAddress.company"></p>
<p x-text="billingAddress.address_line_1"></p>
<p x-show="billingAddress.address_line_2" x-text="billingAddress.address_line_2"></p>
<p x-text="billingAddress.postal_code + ' ' + billingAddress.city"></p>
<p x-text="getCountryName(billingAddress.country_iso)"></p>
</div>
</template>
</div>
</div>
</div>
{# Review Shipping #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Method</h2>
<button type="button" @click="goToStep(2)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="shippingMethod === 'express' ? 'Express Shipping (1-2 business days)' : 'Standard Shipping (3-5 business days)'"></p>
</div>
{# Order Items Review #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Items</h2>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in cartItems" :key="item.product_id">
<div class="py-4 flex items-center gap-4">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-16 h-16 object-cover rounded-lg">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Qty: <span x-text="item.quantity"></span></p>
</div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="submit" :disabled="submitting"
class="px-8 py-3 text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background-color: var(--color-primary)">
<span x-show="!submitting">Place Order</span>
<span x-show="submitting" class="flex items-center">
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('spinner', 'h-5 w-5 animate-spin')"></span>
Processing...
</span>
</button>
</div>
</div>
</div>
{# Right Column - Order Summary #}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 sticky top-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
Order Summary
</h3>
{# Cart Items Preview #}
<div class="space-y-3 mb-6 max-h-64 overflow-y-auto">
<template x-for="item in cartItems" :key="item.product_id">
<div class="flex items-center gap-3">
<div class="relative">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-12 h-12 object-cover rounded">
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
</div>
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
{# Totals #}
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + subtotal.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="shippingCost === 0 ? 'FREE' : '€' + shippingCost.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Tax (incl.)</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + tax.toFixed(2)"></span>
</div>
<div class="flex justify-between text-lg font-bold pt-3 border-t border-gray-200 dark:border-gray-700">
<span class="text-gray-900 dark:text-white">Total</span>
<span style="color: var(--color-primary)" x-text="'€' + total.toFixed(2)"></span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Free shipping on orders over €50
</p>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function checkoutPage() {
return {
...shopLayoutData(),
// State
loading: true,
submitting: false,
error: '',
step: 1,
// Cart data
cartItems: [],
// Customer info
customer: {
first_name: '',
last_name: '',
email: '',
phone: ''
},
// Saved addresses (for logged in customers)
isLoggedIn: false,
savedAddresses: [],
selectedShippingAddressId: '',
selectedBillingAddressId: '',
saveShippingAddress: false,
saveBillingAddress: false,
// Computed filtered addresses by type
get shippingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'shipping');
},
get billingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'billing');
},
// Shipping address
shippingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
// Billing address
billingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
sameAsShipping: true,
shippingMethod: 'standard',
customerNotes: '',
// Countries list
countries: [
{ code: 'LU', name: 'Luxembourg' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'BE', name: 'Belgium' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'AT', name: 'Austria' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'PT', name: 'Portugal' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'HU', name: 'Hungary' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'HR', name: 'Croatia' },
{ code: 'RO', name: 'Romania' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'GR', name: 'Greece' },
{ code: 'IE', name: 'Ireland' },
{ code: 'DK', name: 'Denmark' },
{ code: 'SE', name: 'Sweden' },
{ code: 'FI', name: 'Finland' },
{ code: 'EE', name: 'Estonia' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'MT', name: 'Malta' },
{ code: 'CY', name: 'Cyprus' }
],
// Computed
get subtotal() {
return this.cartItems.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
},
get shippingCost() {
if (this.shippingMethod === 'express') return 9.99;
return this.subtotal >= 50 ? 0 : 5.99;
},
get tax() {
// VAT is included in price, calculate the VAT portion (17% for LU)
const vatRate = 0.17;
return this.subtotal * vatRate / (1 + vatRate);
},
get total() {
return this.subtotal + this.shippingCost;
},
async init() {
console.log('[CHECKOUT] Initializing...');
// Initialize session
if (typeof shopLayoutData === 'function') {
const baseData = shopLayoutData();
if (baseData.init) {
baseData.init.call(this);
}
}
// Check if customer is logged in and pre-fill data
await this.loadCustomerData();
// Load cart
await this.loadCart();
},
async loadCustomerData() {
try {
const response = await fetch('/api/v1/shop/auth/me');
if (response.ok) {
const data = await response.json();
this.isLoggedIn = true;
// Pre-fill customer info
this.customer.first_name = data.first_name || '';
this.customer.last_name = data.last_name || '';
this.customer.email = data.email || '';
this.customer.phone = data.phone || '';
// Pre-fill shipping address with customer name
this.shippingAddress.first_name = data.first_name || '';
this.shippingAddress.last_name = data.last_name || '';
console.log('[CHECKOUT] Customer data loaded');
// Load saved addresses for logged in customer
await this.loadSavedAddresses();
}
} catch (error) {
console.log('[CHECKOUT] No customer logged in or error:', error);
this.isLoggedIn = false;
}
},
async loadSavedAddresses() {
try {
const response = await fetch('/api/v1/shop/addresses');
if (response.ok) {
const data = await response.json();
this.savedAddresses = data.addresses || [];
console.log('[CHECKOUT] Saved addresses loaded:', this.savedAddresses.length);
// Auto-select default shipping address
const defaultShipping = this.shippingAddresses.find(a => a.is_default);
if (defaultShipping) {
this.selectedShippingAddressId = defaultShipping.id;
this.populateFromSavedAddress('shipping');
}
// Auto-select default billing address
const defaultBilling = this.billingAddresses.find(a => a.is_default);
if (defaultBilling) {
this.selectedBillingAddressId = defaultBilling.id;
this.populateFromSavedAddress('billing');
}
}
} catch (error) {
console.error('[CHECKOUT] Failed to load saved addresses:', error);
}
},
populateFromSavedAddress(type) {
const addressId = type === 'shipping' ? this.selectedShippingAddressId : this.selectedBillingAddressId;
const addresses = type === 'shipping' ? this.shippingAddresses : this.billingAddresses;
const targetAddress = type === 'shipping' ? this.shippingAddress : this.billingAddress;
if (!addressId) {
// Clear form when "Enter a new address" is selected
targetAddress.first_name = type === 'shipping' ? this.customer.first_name : '';
targetAddress.last_name = type === 'shipping' ? this.customer.last_name : '';
targetAddress.company = '';
targetAddress.address_line_1 = '';
targetAddress.address_line_2 = '';
targetAddress.city = '';
targetAddress.postal_code = '';
targetAddress.country_iso = 'LU';
return;
}
const savedAddr = addresses.find(a => a.id == addressId);
if (savedAddr) {
targetAddress.first_name = savedAddr.first_name || '';
targetAddress.last_name = savedAddr.last_name || '';
targetAddress.company = savedAddr.company || '';
targetAddress.address_line_1 = savedAddr.address_line_1 || '';
targetAddress.address_line_2 = savedAddr.address_line_2 || '';
targetAddress.city = savedAddr.city || '';
targetAddress.postal_code = savedAddr.postal_code || '';
targetAddress.country_iso = savedAddr.country_iso || 'LU';
console.log(`[CHECKOUT] Populated ${type} address from saved:`, savedAddr.id);
}
},
formatAddressOption(addr) {
const name = `${addr.first_name} ${addr.last_name}`.trim();
const location = `${addr.address_line_1}, ${addr.postal_code} ${addr.city}`;
const defaultBadge = addr.is_default ? ' (Default)' : '';
return `${name} - ${location}${defaultBadge}`;
},
async loadCart() {
this.loading = true;
try {
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.cartItems = data.items || [];
console.log('[CHECKOUT] Cart loaded:', this.cartItems.length, 'items');
}
} catch (error) {
console.error('[CHECKOUT] Failed to load cart:', error);
this.error = 'Failed to load cart';
} finally {
this.loading = false;
}
},
goToStep(newStep) {
// Validate current step before moving forward
if (newStep > this.step) {
if (this.step === 1 && !this.validateStep1()) {
return;
}
}
this.step = newStep;
window.scrollTo({ top: 0, behavior: 'smooth' });
},
validateStep1() {
// Validate customer info
if (!this.customer.first_name || !this.customer.last_name || !this.customer.email) {
this.error = 'Please fill in all required contact fields';
return false;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.customer.email)) {
this.error = 'Please enter a valid email address';
return false;
}
// Validate shipping address
if (!this.shippingAddress.first_name || !this.shippingAddress.last_name ||
!this.shippingAddress.address_line_1 || !this.shippingAddress.city ||
!this.shippingAddress.postal_code || !this.shippingAddress.country_iso) {
this.error = 'Please fill in all required shipping address fields';
return false;
}
// Validate billing address if not same as shipping
if (!this.sameAsShipping) {
if (!this.billingAddress.first_name || !this.billingAddress.last_name ||
!this.billingAddress.address_line_1 || !this.billingAddress.city ||
!this.billingAddress.postal_code || !this.billingAddress.country_iso) {
this.error = 'Please fill in all required billing address fields';
return false;
}
}
this.error = '';
return true;
},
getCountryName(code) {
const country = this.countries.find(c => c.code === code);
return country ? country.name : code;
},
async saveNewAddresses() {
// Save shipping address if checkbox is checked and it's a new address
if (this.saveShippingAddress && !this.selectedShippingAddressId) {
try {
const country = this.countries.find(c => c.code === this.shippingAddress.country_iso);
const addressData = {
address_type: 'shipping',
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_name: country ? country.name : this.shippingAddress.country_iso,
country_iso: this.shippingAddress.country_iso,
is_default: this.shippingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Shipping address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save shipping address:', error);
}
}
// Save billing address if checkbox is checked, it's a new address, and not same as shipping
if (this.saveBillingAddress && !this.selectedBillingAddressId && !this.sameAsShipping) {
try {
const country = this.countries.find(c => c.code === this.billingAddress.country_iso);
const addressData = {
address_type: 'billing',
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_name: country ? country.name : this.billingAddress.country_iso,
country_iso: this.billingAddress.country_iso,
is_default: this.billingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Billing address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save billing address:', error);
}
}
},
async placeOrder() {
this.error = '';
this.submitting = true;
try {
// Save new addresses if requested (only for logged in users with new addresses)
if (this.isLoggedIn) {
await this.saveNewAddresses();
}
// Build order data
const orderData = {
items: this.cartItems.map(item => ({
product_id: item.product_id,
quantity: item.quantity
})),
customer: {
first_name: this.customer.first_name,
last_name: this.customer.last_name,
email: this.customer.email,
phone: this.customer.phone || null
},
shipping_address: {
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_iso: this.shippingAddress.country_iso
},
billing_address: this.sameAsShipping ? null : {
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_iso: this.billingAddress.country_iso
},
shipping_method: this.shippingMethod,
customer_notes: this.customerNotes || null,
session_id: this.sessionId
};
console.log('[CHECKOUT] Placing order:', orderData);
const response = await fetch('/api/v1/shop/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(orderData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to place order');
}
const order = await response.json();
console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
} catch (error) {
console.error('[CHECKOUT] Error placing order:', error);
this.error = error.message || 'Failed to place order. Please try again.';
} finally {
this.submitting = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -1,79 +0,0 @@
{# app/templates/storefront/content-page.html #}
{# Generic CMS content page template #}
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
{% block meta_keywords %}{{ page.meta_keywords or vendor.name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
</h1>
{# Optional: Show vendor override badge for debugging #}
{% if page.vendor_id %}
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Custom {{ vendor.name }} version
</span>
</div>
{% endif %}
{# Published date (optional) #}
{% if page.published_at %}
<div class="text-sm text-gray-500 dark:text-gray-400">
Published {{ page.published_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{# Content #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %}
{# Markdown content - future enhancement: render with markdown library #}
<div class="markdown-content">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
{% else %}
{# HTML content (default) #}
{{ page.content | safe }}{# sanitized: CMS content #}
{% endif %}
</div>
</div>
{# Last updated timestamp #}
{% if page.updated_at %}
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Future enhancement: Add any CMS-specific JavaScript here
// For example:
// - Table of contents generation
// - Anchor link handling
// - Image lightbox
// - Copy code blocks
</script>
{% endblock %}

View File

@@ -1,153 +0,0 @@
{# app/templates/storefront/home.html #}
{% extends "storefront/base.html" %}
{% block title %}Home{% endblock %}
{# Alpine.js component - uses shopLayoutData() from shop-layout.js #}
{% block alpine_data %}shopLayoutData(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Hero Section #}
<div class="hero-section rounded-lg mb-12">
<h1 class="text-4xl md:text-5xl font-bold mb-4">
Welcome to {{ vendor.name }}
</h1>
{% if vendor.tagline %}
<p class="text-xl md:text-2xl mb-6 opacity-90">
{{ vendor.tagline }}
</p>
{% endif %}
{% if vendor.description %}
<p class="text-lg mb-8 opacity-80 max-w-2xl mx-auto">
{{ vendor.description }}
</p>
{% endif %}
<a href="{{ base_url }}shop/products" class="inline-block bg-white text-gray-900 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition">
Shop Now
</a>
</div>
{# Featured Categories (if you have categories) #}
<div class="mb-12">
<h2 class="text-3xl font-bold text-gray-800 dark:text-gray-200 mb-6">
Shop by Category
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{# Placeholder categories - will be loaded via API in future #}
<div class="feature-card text-center cursor-pointer">
<div class="text-4xl mb-3">🏠</div>
<h3 class="font-semibold">Home & Living</h3>
</div>
<div class="feature-card text-center cursor-pointer">
<div class="text-4xl mb-3">👔</div>
<h3 class="font-semibold">Fashion</h3>
</div>
<div class="feature-card text-center cursor-pointer">
<div class="text-4xl mb-3">📱</div>
<h3 class="font-semibold">Electronics</h3>
</div>
<div class="feature-card text-center cursor-pointer">
<div class="text-4xl mb-3">🎨</div>
<h3 class="font-semibold">Arts & Crafts</h3>
</div>
</div>
</div>
{# Featured Products Section #}
<div class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800 dark:text-gray-200">
Featured Products
</h2>
<a href="{{ base_url }}shop/products" class="text-primary hover:underline font-medium">
View All →
</a>
</div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Products Grid #}
<div x-show="!loading" class="product-grid">
{# Coming Soon Notice #}
<div class="col-span-full text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">🛍️</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Products Coming Soon
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
We're setting up our shop. Check back soon for amazing products!
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
<strong>For Developers:</strong> Products will be loaded dynamically from the API once you add them to the database.
</p>
</div>
</div>
</div>
{# Why Shop With Us Section #}
<div class="mb-12">
<h2 class="text-3xl font-bold text-gray-800 dark:text-gray-200 mb-6 text-center">
Why Shop With Us
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="feature-card text-center">
<div class="text-5xl mb-4">🚚</div>
<h3 class="text-xl font-semibold mb-2">Fast Shipping</h3>
<p class="text-gray-600 dark:text-gray-400">
Quick and reliable delivery to your doorstep
</p>
</div>
<div class="feature-card text-center">
<div class="text-5xl mb-4">🔒</div>
<h3 class="text-xl font-semibold mb-2">Secure Payment</h3>
<p class="text-gray-600 dark:text-gray-400">
Your transactions are safe and encrypted
</p>
</div>
<div class="feature-card text-center">
<div class="text-5xl mb-4">💝</div>
<h3 class="text-xl font-semibold mb-2">Quality Guarantee</h3>
<p class="text-gray-600 dark:text-gray-400">
100% satisfaction guaranteed on all products
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Future: Load featured products from API
// Example:
// document.addEventListener('alpine:init', () => {
// Alpine.data('shopHome', () => ({
// ...shopLayoutData(),
// featuredProducts: [],
// loading: true,
//
// async init() {
// await this.loadFeaturedProducts();
// },
//
// async loadFeaturedProducts() {
// try {
// const response = await fetch('/api/v1/shop/products?featured=true&limit=8');
// const data = await response.json();
// this.featuredProducts = data.products;
// } catch (error) {
// console.error('Failed to load products:', error);
// } finally {
// this.loading = false;
// }
// }
// }));
// });
</script>
{% endblock %}

View File

@@ -1,426 +0,0 @@
{# app/templates/storefront/product.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}productDetail(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
</div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Product Detail #}
<div x-show="!loading && product" class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{# Product Images #}
<div class="product-images">
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
<img
:src="selectedImage || '/static/shop/img/placeholder.svg'"
@error="selectedImage = '/static/shop/img/placeholder.svg'"
:alt="product?.marketplace_product?.title"
class="w-full h-auto object-contain"
style="max-height: 600px;"
>
</div>
{# Thumbnail Gallery #}
<div x-show="product?.marketplace_product?.images?.length > 1" class="grid grid-cols-4 gap-2">
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
<img
:src="image"
:alt="`Product image ${index + 1}`"
class="w-full aspect-square object-cover rounded-lg cursor-pointer border-2 transition-all"
:class="selectedImage === image ? 'border-primary' : 'border-transparent hover:border-gray-300'"
@click="selectedImage = image"
>
</template>
</div>
</div>
{# Product Info #}
<div class="product-info-detail">
<h1 x-text="product?.marketplace_product?.title" class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
Product
</h1>
{# Brand & Category #}
<div class="flex flex-wrap gap-4 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<span x-show="product?.marketplace_product?.brand" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
</span>
<span x-show="product?.marketplace_product?.google_product_category" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
</span>
</div>
{# Price #}
<div class="mb-6">
<div x-show="product?.sale_price && product?.sale_price < product?.price">
<span class="text-xl text-gray-500 line-through mr-3"><span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
<span class="text-4xl font-bold text-red-600"><span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
<span class="ml-2 inline-block bg-red-600 text-white px-3 py-1 rounded-full text-sm font-semibold">SALE</span>
</div>
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
<span class="text-4xl font-bold text-gray-800 dark:text-gray-200"><span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
</div>
</div>
{# Availability #}
<div class="mb-6">
<span
x-show="product?.available_inventory > 0"
class="inline-block px-4 py-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-lg font-semibold"
>
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
</span>
<span
x-show="!product?.available_inventory || product?.available_inventory <= 0"
class="inline-block px-4 py-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-lg font-semibold"
>
✗ Out of Stock
</span>
</div>
{# Description #}
<div class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Description</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed" x-text="product?.marketplace_product?.description || 'No description available'"></p>
</div>
{# Additional Details #}
<div x-show="hasAdditionalDetails" class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Product Details</h3>
<ul class="space-y-2">
<li x-show="product?.marketplace_product?.gtin" class="text-sm text-gray-600 dark:text-gray-400">
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
</li>
<li x-show="product?.condition" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Condition:</strong> <span x-text="product?.condition"></span>
</li>
<li x-show="product?.marketplace_product?.color" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
</li>
<li x-show="product?.marketplace_product?.size" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
</li>
<li x-show="product?.marketplace_product?.material" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
</li>
</ul>
</div>
{# Add to Cart Section #}
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
{# Quantity Selector #}
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
<div class="mb-4">
<label class="block font-semibold text-lg mb-2">Quantity:</label>
<div class="flex items-center gap-2">
<button
@click="decreaseQuantity()"
:disabled="quantity <= (product?.min_quantity || 1)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<input
type="number"
x-model.number="quantity"
:min="product?.min_quantity || 1"
:max="product?.max_quantity || product?.available_inventory"
class="w-20 text-center px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg font-semibold dark:bg-gray-700 dark:text-white"
@change="validateQuantity()"
>
<button
@click="increaseQuantity()"
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
+
</button>
</div>
</div>
{# Add to Cart Button #}
<button
@click="addToCart()"
:disabled="!canAddToCart || addingToCart"
class="w-full px-6 py-4 bg-primary text-white rounded-lg font-semibold text-lg hover:bg-primary-dark transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!addingToCart">
🛒 Add to Cart
</span>
<span x-show="addingToCart">
<span class="inline-block animate-spin"></span> Adding...
</span>
</button>
{# Total Price #}
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-center">
<strong class="text-xl">Total:</strong> <span class="text-2xl font-bold"><span x-text="totalPrice.toFixed(2)"></span></span>
</div>
</div>
</div>
</div>
{# Related Products #}
<div x-show="relatedProducts.length > 0" class="mt-12 pt-12 border-t-2 border-gray-200 dark:border-gray-700">
<h2 class="text-3xl font-bold mb-6">You May Also Like</h2>
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<img
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="related.marketplace_product?.title"
class="w-full h-48 object-cover"
>
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
</a>
<p class="text-2xl font-bold text-primary">
<span x-text="parseFloat(related.price).toFixed(2)"></span>
</p>
</div>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass product ID from template to JavaScript
window.PRODUCT_ID = {{ product_id }};
window.VENDOR_ID = {{ vendor.id }};
document.addEventListener('alpine:init', () => {
Alpine.data('productDetail', () => {
const baseData = shopLayoutData();
return {
...baseData,
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
vendorId: window.VENDOR_ID,
productId: window.PRODUCT_ID,
// Computed properties
get canAddToCart() {
return this.product?.is_active &&
this.product?.available_inventory > 0 &&
this.quantity > 0 &&
this.quantity <= this.product?.available_inventory;
},
get totalPrice() {
const price = this.product?.sale_price || this.product?.price || 0;
return price * this.quantity;
},
get hasAdditionalDetails() {
return this.product?.marketplace_product?.gtin ||
this.product?.condition ||
this.product?.marketplace_product?.color ||
this.product?.marketplace_product?.size ||
this.product?.marketplace_product?.material;
},
// Initialize
async init() {
console.log('[SHOP] Product detail page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
console.log('[SHOP] Product ID:', this.productId);
console.log('[SHOP] Vendor ID:', this.vendorId);
console.log('[SHOP] Session ID:', this.sessionId);
await this.loadProduct();
},
// Load product details
async loadProduct() {
this.loading = true;
try {
console.log(`[SHOP] Loading product ${this.productId}...`);
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
if (!response.ok) {
throw new Error('Product not found');
}
this.product = await response.json();
console.log('[SHOP] Product loaded:', this.product);
// Set default image
if (this.product?.marketplace_product?.image_link) {
this.selectedImage = this.product.marketplace_product.image_link;
}
// Set initial quantity
this.quantity = this.product?.min_quantity || 1;
// Load related products
await this.loadRelatedProducts();
} catch (error) {
console.error('[SHOP] Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '{{ base_url }}shop/products';
}, 2000);
} finally {
this.loading = false;
}
},
// Load related products
async loadRelatedProducts() {
try {
const response = await fetch(`/api/v1/shop/products?limit=4`);
if (response.ok) {
const data = await response.json();
// Filter out current product
this.relatedProducts = data.products
.filter(p => p.id !== parseInt(this.productId))
.slice(0, 4);
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
}
} catch (error) {
console.error('[SHOP] Failed to load related products:', error);
}
},
// Quantity controls
increaseQuantity() {
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < max) {
this.quantity++;
}
},
decreaseQuantity() {
const min = this.product?.min_quantity || 1;
if (this.quantity > min) {
this.quantity--;
}
},
validateQuantity() {
const min = this.product?.min_quantity || 1;
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < min) {
this.quantity = min;
} else if (this.quantity > max) {
this.quantity = max;
}
},
// Add to cart
async addToCart() {
if (!this.canAddToCart) {
console.warn('[SHOP] Cannot add to cart:', {
canAddToCart: this.canAddToCart,
isActive: this.product?.is_active,
inventory: this.product?.available_inventory,
quantity: this.quantity
});
return;
}
this.addingToCart = true;
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: parseInt(this.productId),
quantity: this.quantity
};
console.log('[SHOP] Adding to cart:', {
url,
sessionId: this.sessionId,
productId: this.productId,
quantity: this.quantity,
payload
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
console.log('[SHOP] Add to cart response:', {
status: response.status,
ok: response.ok
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += this.quantity;
this.showToast(
`${this.quantity} item(s) added to cart!`,
'success'
);
// Reset quantity to minimum
this.quantity = this.product?.min_quantity || 1;
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error response:', error);
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
}
};
});
});
</script>
{% endblock %}

View File

@@ -1,246 +0,0 @@
{# app/templates/storefront/products.html #}
{% extends "storefront/base.html" %}
{% block title %}Products{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopProducts(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Products</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
All Products
</h1>
<p class="text-gray-600 dark:text-gray-400">
Discover our complete collection
</p>
</div>
{# Filters & Search Bar #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
{# Search #}
<div class="md:col-span-2">
<input
type="text"
placeholder="Search products..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
>
</div>
{# Category Filter #}
<div>
<select class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option>
<option value="home">Home & Living</option>
<option value="fashion">Fashion</option>
<option value="electronics">Electronics</option>
<option value="arts">Arts & Crafts</option>
</select>
</div>
{# Sort #}
<div>
<select class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="newest">Newest First</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="popular">Most Popular</option>
</select>
</div>
</div>
</div>
{# Products Grid #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Products Grid #}
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(product)"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add to Cart</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# No Products Message #}
<div x-show="!loading && products.length === 0" class="col-span-full text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">📦</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Products Yet
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Products will appear here once they are added to the catalog.
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
<strong>For Developers:</strong> Add products through the vendor dashboard or admin panel.
</p>
</div>
{# Pagination (hidden for now) #}
<div x-show="false" class="mt-8 flex justify-center">
<div class="flex gap-2">
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
Previous
</button>
<button class="px-4 py-2 bg-primary text-white rounded-lg">
1
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
2
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
3
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
Next
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shopProducts', () => ({
...shopLayoutData(),
products: [],
loading: true,
filters: {
search: '',
category: '',
sort: 'newest'
},
pagination: {
page: 1,
perPage: 12,
total: 0
},
async init() {
console.log('[SHOP] Products page initializing...');
await this.loadProducts();
},
async loadProducts() {
this.loading = true;
try {
const params = new URLSearchParams({
skip: (this.pagination.page - 1) * this.pagination.perPage,
limit: this.pagination.perPage
});
// Add search filter if present
if (this.filters.search) {
params.append('search', this.filters.search);
}
console.log(`[SHOP] Loading products from /api/v1/shop/products?${params}`);
const response = await fetch(`/api/v1/shop/products?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
this.products = data.products;
this.pagination.total = data.total;
} catch (error) {
console.error('[SHOP] Failed to load products:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
}
},
filterProducts() {
this.loading = true;
this.loadProducts();
},
// formatPrice is inherited from shopLayoutData() via spread operator
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}

View File

@@ -1,327 +0,0 @@
{# app/templates/storefront/search.html #}
{# noqa: FE-001 - Shop uses custom pagination with vendor-themed styling (CSS variables) #}
{% extends "storefront/base.html" %}
{% block title %}Search Results{% if query %} for "{{ query }}"{% endif %}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopSearch(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Search</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
<span x-show="query">Search Results for "<span x-text="query"></span>"</span>
<span x-show="!query">Search Products</span>
</h1>
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
Found <span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span>
</p>
</div>
{# Search Bar #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<form @submit.prevent="performSearch" class="flex gap-4">
<div class="flex-1 relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
<span x-html="$icon('search', 'w-5 h-5')"></span>
</span>
<input
type="text"
x-model="searchInput"
placeholder="Search products by name, description, SKU..."
class="w-full pl-10 pr-4 py-3 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"
autofocus
>
</div>
<button
type="submit"
class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center gap-2"
style="background-color: var(--color-primary)"
:disabled="searching"
>
<span x-show="!searching" x-html="$icon('search', 'w-5 h-5')"></span>
<span x-show="searching" class="spinner-sm"></span>
<span class="hidden sm:inline">Search</span>
</button>
</form>
</div>
{# Results Area #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# No Query Yet #}
<div x-show="!loading && !query" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('search', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Start Your Search
</h3>
<p class="text-gray-600 dark:text-gray-400">
Enter a search term above to find products
</p>
</div>
{# Search Results Grid #}
<div x-show="!loading && query && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
{# Brand badge if available #}
<div x-show="product.brand" class="mb-2">
<span class="inline-block px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded" x-text="product.brand"></span>
</div>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(product)"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# No Results Message #}
<div x-show="!loading && query && products.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('search-x', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Results Found
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
No products match "<span x-text="query" class="font-medium"></span>"
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
Try different keywords or check the spelling
</p>
</div>
{# Pagination #}
<div x-show="!loading && query && totalPages > 1" class="mt-8 flex justify-center">
<div class="flex gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<template x-for="page in visiblePages" :key="page">
<button
@click="goToPage(page)"
:class="page === currentPage ? 'bg-primary text-white' : 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="px-4 py-2 rounded-lg"
:style="page === currentPage ? 'background-color: var(--color-primary)' : ''"
x-text="page"
></button>
</template>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shopSearch', () => ({
...shopLayoutData(),
// Search state
searchInput: '',
query: '',
products: [],
total: 0,
loading: false,
searching: false,
// Pagination
currentPage: 1,
perPage: 12,
get totalPages() {
return Math.ceil(this.total / this.perPage);
},
get visiblePages() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
let start = Math.max(1, current - 2);
let end = Math.min(total, current + 2);
// Adjust to always show 5 pages if possible
if (end - start < 4) {
if (start === 1) {
end = Math.min(total, 5);
} else {
start = Math.max(1, total - 4);
}
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
async init() {
console.log('[SHOP] Search page initializing...');
// Check for query parameter in URL
const urlParams = new URLSearchParams(window.location.search);
const urlQuery = urlParams.get('q');
if (urlQuery) {
this.searchInput = urlQuery;
this.query = urlQuery;
await this.loadResults();
}
},
async performSearch() {
if (!this.searchInput.trim()) {
return;
}
this.query = this.searchInput.trim();
this.currentPage = 1;
// Update URL without reload
const url = new URL(window.location);
url.searchParams.set('q', this.query);
window.history.pushState({}, '', url);
await this.loadResults();
},
async loadResults() {
if (!this.query) return;
this.loading = true;
this.searching = true;
try {
const params = new URLSearchParams({
q: this.query,
skip: (this.currentPage - 1) * this.perPage,
limit: this.perPage
});
console.log(`[SHOP] Searching: /api/v1/shop/products/search?${params}`);
const response = await fetch(`/api/v1/shop/products/search?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Search found ${data.total} results`);
this.products = data.products;
this.total = data.total;
} catch (error) {
console.error('[SHOP] Search failed:', error);
this.showToast('Search failed. Please try again.', 'error');
this.products = [];
this.total = 0;
} finally {
this.loading = false;
this.searching = false;
}
},
async goToPage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
await this.loadResults();
// Scroll to top of results
window.scrollTo({ top: 0, behavior: 'smooth' });
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}