feat(auth): translate LoginPage + RegisterPage to 9 locales

Both auth forms now go through t('auth.login.*') and
t('auth.register.*'):
- Title + subtitle, email / password / username labels with
  username placeholder and password-min-length placeholder.
- Submit button toggles between idle and loading states.
- "or" divider, "Continue with Google" button, and the
  switch-to-other-form footer link.
- Inline validation errors: reserved username, username regex,
  password length, plus the generic catch-all from the API.

Internal /editor and /login / /register links wrapped in
localize() so a Spanish user who registers stays at /es/editor
after success.

AboutPage (519 lines) deferred to its own commit — too dense for
the same change.
This commit is contained in:
David Montero Crespo 2026-05-09 03:23:09 -03:00
parent ecc35f72cb
commit 20b22b1398
11 changed files with 308 additions and 30 deletions

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Über PayPal spenden"
}
},
"auth": {
"login": {
"title": "Anmelden",
"subtitle": "um zu Velxio fortzufahren",
"email": "E-Mail",
"password": "Passwort",
"signIn": "Anmelden",
"signingIn": "Anmelden…",
"or": "oder",
"continueGoogle": "Mit Google fortfahren",
"noAccount": "Noch keinen Account?",
"failed": "Anmeldung fehlgeschlagen."
},
"register": {
"title": "Konto erstellen",
"subtitle": "Kostenlos loslegen",
"username": "Benutzername",
"usernamePlaceholder": "z. B. alice",
"passwordPlaceholder": "Mindestens 8 Zeichen",
"create": "Konto erstellen",
"creating": "Konto wird erstellt…",
"haveAccount": "Schon einen Account?",
"errors": {
"reserved": "Dieser Benutzername ist reserviert.",
"usernameRules": "Benutzername muss 3-30 Zeichen lang sein (a-z, 0-9, _, -)",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen haben.",
"failed": "Registrierung fehlgeschlagen."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Sprachmodus",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Donate via PayPal"
}
},
"auth": {
"login": {
"title": "Sign in",
"subtitle": "to continue to Velxio",
"email": "Email",
"password": "Password",
"signIn": "Sign in",
"signingIn": "Signing in…",
"or": "or",
"continueGoogle": "Continue with Google",
"noAccount": "Don't have an account?",
"failed": "Login failed."
},
"register": {
"title": "Create account",
"subtitle": "Start building for free",
"username": "Username",
"usernamePlaceholder": "e.g. alice",
"passwordPlaceholder": "At least 8 characters",
"create": "Create account",
"creating": "Creating account…",
"haveAccount": "Already have an account?",
"errors": {
"reserved": "That username is reserved.",
"usernameRules": "Username must be 3-30 chars (a-z, 0-9, _, -)",
"passwordTooShort": "Password must be at least 8 characters.",
"failed": "Registration failed."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Language mode",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Donar con PayPal"
}
},
"auth": {
"login": {
"title": "Iniciar sesión",
"subtitle": "para continuar en Velxio",
"email": "Correo electrónico",
"password": "Contraseña",
"signIn": "Iniciar sesión",
"signingIn": "Iniciando sesión…",
"or": "o",
"continueGoogle": "Continuar con Google",
"noAccount": "¿No tienes cuenta?",
"failed": "Error al iniciar sesión."
},
"register": {
"title": "Crear cuenta",
"subtitle": "Empieza a construir gratis",
"username": "Nombre de usuario",
"usernamePlaceholder": "ej. alice",
"passwordPlaceholder": "Al menos 8 caracteres",
"create": "Crear cuenta",
"creating": "Creando cuenta…",
"haveAccount": "¿Ya tienes cuenta?",
"errors": {
"reserved": "Ese nombre de usuario está reservado.",
"usernameRules": "El usuario debe tener 3-30 caracteres (a-z, 0-9, _, -)",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres.",
"failed": "Error al registrarse."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Modo de lenguaje",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Faire un don via PayPal"
}
},
"auth": {
"login": {
"title": "Se connecter",
"subtitle": "pour continuer sur Velxio",
"email": "E-mail",
"password": "Mot de passe",
"signIn": "Se connecter",
"signingIn": "Connexion…",
"or": "ou",
"continueGoogle": "Continuer avec Google",
"noAccount": "Pas encore de compte ?",
"failed": "Échec de la connexion."
},
"register": {
"title": "Créer un compte",
"subtitle": "Commencez à créer gratuitement",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "ex. alice",
"passwordPlaceholder": "Au moins 8 caractères",
"create": "Créer un compte",
"creating": "Création du compte…",
"haveAccount": "Vous avez déjà un compte ?",
"errors": {
"reserved": "Ce nom d'utilisateur est réservé.",
"usernameRules": "Le nom d'utilisateur doit faire 3 à 30 caractères (a-z, 0-9, _, -)",
"passwordTooShort": "Le mot de passe doit faire au moins 8 caractères.",
"failed": "L'inscription a échoué."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Mode de langage",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Dona con PayPal"
}
},
"auth": {
"login": {
"title": "Accedi",
"subtitle": "per continuare su Velxio",
"email": "Email",
"password": "Password",
"signIn": "Accedi",
"signingIn": "Accesso…",
"or": "o",
"continueGoogle": "Continua con Google",
"noAccount": "Non hai un account?",
"failed": "Accesso non riuscito."
},
"register": {
"title": "Crea account",
"subtitle": "Inizia a costruire gratis",
"username": "Nome utente",
"usernamePlaceholder": "es. alice",
"passwordPlaceholder": "Almeno 8 caratteri",
"create": "Crea account",
"creating": "Creazione account…",
"haveAccount": "Hai già un account?",
"errors": {
"reserved": "Questo nome utente è riservato.",
"usernameRules": "Il nome utente deve essere di 3-30 caratteri (a-z, 0-9, _, -)",
"passwordTooShort": "La password deve avere almeno 8 caratteri.",
"failed": "Registrazione non riuscita."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Modalità linguaggio",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "PayPal で寄付する"
}
},
"auth": {
"login": {
"title": "サインイン",
"subtitle": "Velxio を続けるには",
"email": "メールアドレス",
"password": "パスワード",
"signIn": "サインイン",
"signingIn": "サインイン中…",
"or": "または",
"continueGoogle": "Google で続ける",
"noAccount": "アカウントをお持ちでないですか?",
"failed": "サインインに失敗しました。"
},
"register": {
"title": "アカウント作成",
"subtitle": "無料で始めましょう",
"username": "ユーザー名",
"usernamePlaceholder": "例alice",
"passwordPlaceholder": "8 文字以上",
"create": "アカウント作成",
"creating": "アカウントを作成中…",
"haveAccount": "すでにアカウントをお持ちですか?",
"errors": {
"reserved": "このユーザー名は予約されています。",
"usernameRules": "ユーザー名は 3〜30 文字a-z、0-9、_、-)で指定してください",
"passwordTooShort": "パスワードは 8 文字以上にしてください。",
"failed": "登録に失敗しました。"
}
}
},
"editor": {
"toolbar": {
"languageMode": "言語モード",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Doar via PayPal"
}
},
"auth": {
"login": {
"title": "Entrar",
"subtitle": "para continuar no Velxio",
"email": "E-mail",
"password": "Senha",
"signIn": "Entrar",
"signingIn": "Entrando…",
"or": "ou",
"continueGoogle": "Continuar com Google",
"noAccount": "Não tem uma conta?",
"failed": "Falha ao entrar."
},
"register": {
"title": "Criar conta",
"subtitle": "Comece a construir grátis",
"username": "Nome de usuário",
"usernamePlaceholder": "ex. alice",
"passwordPlaceholder": "Pelo menos 8 caracteres",
"create": "Criar conta",
"creating": "Criando conta…",
"haveAccount": "Já tem uma conta?",
"errors": {
"reserved": "Este nome de usuário é reservado.",
"usernameRules": "O usuário deve ter 3-30 caracteres (a-z, 0-9, _, -)",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres.",
"failed": "Falha no cadastro."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Modo de linguagem",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "Пожертвовать через PayPal"
}
},
"auth": {
"login": {
"title": "Войти",
"subtitle": "чтобы продолжить в Velxio",
"email": "Email",
"password": "Пароль",
"signIn": "Войти",
"signingIn": "Вход…",
"or": "или",
"continueGoogle": "Продолжить с Google",
"noAccount": "Нет аккаунта?",
"failed": "Не удалось войти."
},
"register": {
"title": "Создать аккаунт",
"subtitle": "Начните бесплатно",
"username": "Имя пользователя",
"usernamePlaceholder": "напр. alice",
"passwordPlaceholder": "Минимум 8 символов",
"create": "Создать аккаунт",
"creating": "Создание аккаунта…",
"haveAccount": "Уже есть аккаунт?",
"errors": {
"reserved": "Это имя пользователя зарезервировано.",
"usernameRules": "Имя пользователя должно быть 3-30 символов (a-z, 0-9, _, -)",
"passwordTooShort": "Пароль должен быть не менее 8 символов.",
"failed": "Регистрация не удалась."
}
}
},
"editor": {
"toolbar": {
"languageMode": "Режим языка",

View File

@ -77,6 +77,36 @@
"ctaPaypal": "通过 PayPal 捐赠"
}
},
"auth": {
"login": {
"title": "登录",
"subtitle": "继续使用 Velxio",
"email": "邮箱",
"password": "密码",
"signIn": "登录",
"signingIn": "正在登录…",
"or": "或",
"continueGoogle": "使用 Google 继续",
"noAccount": "还没有账户?",
"failed": "登录失败。"
},
"register": {
"title": "创建账户",
"subtitle": "免费开始构建",
"username": "用户名",
"usernamePlaceholder": "如 alice",
"passwordPlaceholder": "至少 8 个字符",
"create": "创建账户",
"creating": "正在创建账户…",
"haveAccount": "已有账户?",
"errors": {
"reserved": "该用户名已被保留。",
"usernameRules": "用户名必须为 3-30 个字符a-z、0-9、_、-",
"passwordTooShort": "密码至少需要 8 个字符。",
"failed": "注册失败。"
}
}
},
"editor": {
"toolbar": {
"languageMode": "语言模式",

View File

@ -1,11 +1,15 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { login, initiateGoogleLogin } from '../services/authService';
import { useAuthStore } from '../store/useAuthStore';
import { useLocalizedHref } from '../i18n/useLocalizedNavigate';
import { useSEO } from '../utils/useSEO';
import { trackLogin } from '../utils/analytics';
export const LoginPage: React.FC = () => {
const { t } = useTranslation();
const localize = useLocalizedHref();
useSEO({
title: 'Sign In — Velxio',
description:
@ -29,9 +33,9 @@ export const LoginPage: React.FC = () => {
const user = await login(email, password);
trackLogin('email');
setUser(user);
navigate(searchParams.get('redirect') || '/');
navigate(searchParams.get('redirect') || localize('/'));
} catch (err: any) {
setError(err?.response?.data?.detail || 'Login failed.');
setError(err?.response?.data?.detail || t('auth.login.failed'));
} finally {
setLoading(false);
}
@ -40,14 +44,14 @@ export const LoginPage: React.FC = () => {
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Sign in</h1>
<p className="ap-card-sub">to continue to Velxio</p>
<h1 className="ap-card-title">{t('auth.login.title')}</h1>
<p className="ap-card-sub">{t('auth.login.subtitle')}</p>
{error && <div className="ap-error">{error}</div>}
<form onSubmit={handleSubmit} className="ap-form">
<div className="ap-field">
<label className="ap-label">Email</label>
<label className="ap-label">{t('auth.login.email')}</label>
<input
type="email"
value={email}
@ -58,7 +62,7 @@ export const LoginPage: React.FC = () => {
/>
</div>
<div className="ap-field">
<label className="ap-label">Password</label>
<label className="ap-label">{t('auth.login.password')}</label>
<input
type="password"
value={password}
@ -68,11 +72,11 @@ export const LoginPage: React.FC = () => {
/>
</div>
<button type="submit" disabled={loading} className="ap-btn-primary">
{loading ? 'Signing in…' : 'Sign in'}
{loading ? t('auth.login.signingIn') : t('auth.login.signIn')}
</button>
</form>
<div className="ap-divider">or</div>
<div className="ap-divider">{t('auth.login.or')}</div>
<button
onClick={() => {
@ -99,13 +103,13 @@ export const LoginPage: React.FC = () => {
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
Continue with Google
{t('auth.login.continueGoogle')}
</button>
<p className="ap-footer">
Don't have an account?{' '}
<Link to="/register" className="ap-link">
Sign up
{t('auth.login.noAccount')}{' '}
<Link to={localize('/register')} className="ap-link">
{t('header.auth.signUp')}
</Link>
</p>
</div>

View File

@ -1,12 +1,16 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { register, initiateGoogleLogin } from '../services/authService';
import { useAuthStore } from '../store/useAuthStore';
import { useLocalizedHref } from '../i18n/useLocalizedNavigate';
import { RESERVED_USERNAMES } from '../utils/reservedUsernames';
import { useSEO } from '../utils/useSEO';
import { trackSignUp } from '../utils/analytics';
export const RegisterPage: React.FC = () => {
const { t } = useTranslation();
const localize = useLocalizedHref();
useSEO({
title: 'Create Account — Velxio',
description:
@ -24,8 +28,8 @@ export const RegisterPage: React.FC = () => {
const validateUsername = (name: string): string | null => {
const lower = name.toLowerCase();
if (RESERVED_USERNAMES.has(lower)) return 'That username is reserved.';
if (!/^[a-z0-9_-]{3,30}$/.test(lower)) return 'Username must be 3-30 chars (a-z, 0-9, _, -)';
if (RESERVED_USERNAMES.has(lower)) return t('auth.register.errors.reserved');
if (!/^[a-z0-9_-]{3,30}$/.test(lower)) return t('auth.register.errors.usernameRules');
return null;
};
@ -38,7 +42,7 @@ export const RegisterPage: React.FC = () => {
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters.');
setError(t('auth.register.errors.passwordTooShort'));
return;
}
@ -47,9 +51,9 @@ export const RegisterPage: React.FC = () => {
const user = await register(username.toLowerCase(), email, password);
trackSignUp('email');
setUser(user);
navigate('/editor');
navigate(localize('/editor'));
} catch (err: any) {
setError(err?.response?.data?.detail || 'Registration failed.');
setError(err?.response?.data?.detail || t('auth.register.errors.failed'));
} finally {
setLoading(false);
}
@ -58,14 +62,14 @@ export const RegisterPage: React.FC = () => {
return (
<div className="ap-page">
<div className="ap-card">
<h1 className="ap-card-title">Create account</h1>
<p className="ap-card-sub">Start building for free</p>
<h1 className="ap-card-title">{t('auth.register.title')}</h1>
<p className="ap-card-sub">{t('auth.register.subtitle')}</p>
{error && <div className="ap-error">{error}</div>}
<form onSubmit={handleSubmit} className="ap-form">
<div className="ap-field">
<label className="ap-label">Username</label>
<label className="ap-label">{t('auth.register.username')}</label>
<input
type="text"
value={username}
@ -73,11 +77,11 @@ export const RegisterPage: React.FC = () => {
required
className="ap-input"
autoFocus
placeholder="e.g. alice"
placeholder={t('auth.register.usernamePlaceholder')}
/>
</div>
<div className="ap-field">
<label className="ap-label">Email</label>
<label className="ap-label">{t('auth.login.email')}</label>
<input
type="email"
value={email}
@ -87,22 +91,22 @@ export const RegisterPage: React.FC = () => {
/>
</div>
<div className="ap-field">
<label className="ap-label">Password</label>
<label className="ap-label">{t('auth.login.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="ap-input"
placeholder="At least 8 characters"
placeholder={t('auth.register.passwordPlaceholder')}
/>
</div>
<button type="submit" disabled={loading} className="ap-btn-primary">
{loading ? 'Creating account…' : 'Create account'}
{loading ? t('auth.register.creating') : t('auth.register.create')}
</button>
</form>
<div className="ap-divider">or</div>
<div className="ap-divider">{t('auth.login.or')}</div>
<button
onClick={() => {
@ -129,13 +133,13 @@ export const RegisterPage: React.FC = () => {
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
Continue with Google
{t('auth.login.continueGoogle')}
</button>
<p className="ap-footer">
Already have an account?{' '}
<Link to="/login" className="ap-link">
Sign in
{t('auth.register.haveAccount')}{' '}
<Link to={localize('/login')} className="ap-link">
{t('header.auth.signIn')}
</Link>
</p>
</div>