Files
orion/app/templates/shop/account/profile.html
Samir Boulahtit 82c07c165f feat: add customer profile, VAT alignment, and fix shop auth
Customer Profile:
- Add profile API (GET/PUT /api/v1/shop/profile)
- Add password change endpoint (PUT /api/v1/shop/profile/password)
- Implement full profile page with preferences and password sections
- Add CustomerPasswordChange schema

Shop Authentication Fixes:
- Add Authorization header to all shop account API calls
- Fix orders, order-detail, messages pages authentication
- Add proper redirect to login on 401 responses
- Fix toast message showing noqa comment in shop-layout.js

VAT Calculation:
- Add shared VAT utility (app/utils/vat.py)
- Add VAT fields to Order model (vat_regime, vat_rate, etc.)
- Align order VAT calculation with invoice settings
- Add migration for VAT fields on orders

Validation Framework:
- Fix base_validator.py with missing methods
- Add validate_file, output_results, get_exit_code methods
- Fix validate_all.py import issues

Documentation:
- Add launch-readiness.md tracking OMS status
- Update to 95% feature complete

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:31:48 +01:00

546 lines
26 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">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900 dark:text-white">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">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
<!-- 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">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<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)">
<svg x-show="savingProfile" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<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)">
<svg x-show="savingPreferences" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<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)">
<svg x-show="changingPassword" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<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(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
}
}
</script>
{% endblock %}