Some checks failed
Test 5 (storefront password reset + customer dashboard) surfaced five
issues that all traced back to missing i18n plumbing:
- Forgot-password email arrived in EN regardless of storefront locale —
handler now prefers request.state.language over customer.preferred_language,
and loyalty self-enrollment backfills preferred_language for new + returning
customers so future locale-sensitive flows hit the right language without
being told twice.
- reset-password.html rendered "undefined" icon boxes because $icon magic
wasn't loaded in the standalone page — replaced with inline SVGs matching
the forgot-password.html convention.
- reset-password.html was hardcoded English: added lang attr, full _()
sweep (22 new auth.* keys × 4 locales), language selector, and JS
validation strings exposed via tojson.
- "Continue shopping" CTA renamed to "Back to Home" (auth.back_to_home,
4 locales) on login + forgot + reset — loyalty storefronts have no
catalog to continue to, mirroring the earlier enroll-success rename.
- /account dashboard, profile, addresses were hardcoded English in the
body (menu was FR because base layout uses _()). New customers.storefront
.pages.{dashboard,profile,addresses}.* namespace (~80 keys × 4 locales),
templates updated, Alpine JS strings injected via window.__*I18n.
18 files, 18 changed; arch validation: 126 warnings before = 126 after,
mkdocs --strict clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
316 lines
16 KiB
HTML
316 lines
16 KiB
HTML
{# 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 - {{ store.name }}</title>
|
|
<!-- Fonts: Local fallback + Google Fonts -->
|
|
<link href="{{ static_v(request, 'static', path='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="store-theme-variables">
|
|
:root {
|
|
{% for key, value in theme.css_variables.items() %}
|
|
{{ key }}: {{ value }};
|
|
{% endfor %}
|
|
}
|
|
|
|
{# Custom CSS from store 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='storefront/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="{{ store.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">{{ store.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">
|
|
{{ _("auth.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">{{ _("common.email") }}</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="{{ _('auth.email_placeholder') }}"
|
|
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">{{ _("auth.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="{{ _('auth.password_placeholder') }}"
|
|
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">{{ _("auth.remember_me") }}</span>
|
|
</label>
|
|
<a href="{{ base_url }}account/forgot-password"
|
|
class="text-sm font-medium hover:underline"
|
|
style="color: var(--color-primary);">
|
|
{{ _("auth.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">{{ _("auth.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>
|
|
{{ _("auth.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">{{ _("auth.no_account") }}</span>
|
|
<a class="text-sm font-medium hover:underline ml-1"
|
|
style="color: var(--color-primary);"
|
|
href="{{ base_url }}account/register">
|
|
{{ _("auth.create_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 }}">
|
|
← {{ _("auth.back_to_home") }}
|
|
</a>
|
|
</p>
|
|
|
|
<!-- Language selector (always show all platform languages on login page) -->
|
|
<div class="flex items-center justify-center gap-2 mt-6"
|
|
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
|
<template x-for="lang in languages" :key="lang">
|
|
<button
|
|
@click="setLanguage(lang)"
|
|
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
|
:class="currentLang === lang
|
|
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
|
x-text="lang.toUpperCase()"
|
|
></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Icons (registers $icon magic helper for Alpine) -->
|
|
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
|
|
|
<!-- Alpine.js v3 -->
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
|
|
|
<!-- Login Logic -->
|
|
<script>
|
|
function languageSelector(currentLang, enabledLanguages) {
|
|
return {
|
|
currentLang: currentLang || 'fr',
|
|
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
|
async setLanguage(lang) {
|
|
if (lang === this.currentLang) return;
|
|
await fetch('/api/v1/platform/language/set', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ language: lang }),
|
|
});
|
|
window.location.reload();
|
|
},
|
|
};
|
|
}
|
|
|
|
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/storefront/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 }}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>
|