Convert 90 inline SVG icons to use the shared $icon() helper function across shop and vendor templates for consistency and maintainability. Templates updated: - Shop: checkout, products, login, register, forgot/reset-password - Shop account: addresses, dashboard, messages, order-detail, orders, profile - Vendor: billing, login, onboarding, team, landing pages (4 variants) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
525 lines
24 KiB
HTML
525 lines
24 KiB
HTML
{# app/templates/shop/account/profile.html #}
|
|
{% extends "shop/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 %}
|