Phase 5: Translate Auth Pages - login and signup

- Create auth.json translation files for es, en, ca
- Translate login page: title, subtitle, form labels, buttons, footer
- Translate signup page: invite code step and account creation step
- Translate signup/[code] redirect page
- Update IntlProvider to load auth namespace
- Update test expectations to match Spanish translations (default language)
- All frontend and e2e tests passing
This commit is contained in:
counterweight 2025-12-25 22:14:04 +01:00
parent a5a1a2c1ad
commit 7dd13292a0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 188 additions and 47 deletions

View file

@ -14,11 +14,14 @@ import caNavigation from "../../locales/ca/navigation.json";
import esExchange from "../../locales/es/exchange.json"; import esExchange from "../../locales/es/exchange.json";
import enExchange from "../../locales/en/exchange.json"; import enExchange from "../../locales/en/exchange.json";
import caExchange from "../../locales/ca/exchange.json"; import caExchange from "../../locales/ca/exchange.json";
import esAuth from "../../locales/es/auth.json";
import enAuth from "../../locales/en/auth.json";
import caAuth from "../../locales/ca/auth.json";
const messages = { const messages = {
es: { common: esCommon, navigation: esNavigation, exchange: esExchange }, es: { common: esCommon, navigation: esNavigation, exchange: esExchange, auth: esAuth },
en: { common: enCommon, navigation: enNavigation, exchange: enExchange }, en: { common: enCommon, navigation: enNavigation, exchange: enExchange, auth: enAuth },
ca: { common: caCommon, navigation: caNavigation, exchange: caExchange }, ca: { common: caCommon, navigation: caNavigation, exchange: caExchange, auth: caAuth },
}; };
interface IntlProviderProps { interface IntlProviderProps {

View file

@ -19,21 +19,21 @@ afterEach(() => cleanup());
test("renders login form with title", () => { test("renders login form with title", () => {
renderWithProviders(<LoginPage />); renderWithProviders(<LoginPage />);
expect(screen.getByText("Welcome back")).toBeDefined(); expect(screen.getByText("Bienvenido de nuevo")).toBeDefined();
}); });
test("renders email and password inputs", () => { test("renders email and password inputs", () => {
renderWithProviders(<LoginPage />); renderWithProviders(<LoginPage />);
expect(screen.getByLabelText("Email")).toBeDefined(); expect(screen.getByLabelText("Correo electrónico")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined(); expect(screen.getByLabelText("Contraseña")).toBeDefined();
}); });
test("renders sign in button", () => { test("renders sign in button", () => {
renderWithProviders(<LoginPage />); renderWithProviders(<LoginPage />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined(); expect(screen.getByRole("button", { name: "Iniciar sesión" })).toBeDefined();
}); });
test("renders link to signup", () => { test("renders link to signup", () => {
renderWithProviders(<LoginPage />); renderWithProviders(<LoginPage />);
expect(screen.getByText("Sign up")).toBeDefined(); expect(screen.getByText("Regístrate")).toBeDefined();
}); });

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context"; import { useAuth } from "../auth-context";
import { authFormStyles as styles } from "../styles/auth-form"; import { authFormStyles as styles } from "../styles/auth-form";
import { LanguageSelector } from "../components/LanguageSelector"; import { LanguageSelector } from "../components/LanguageSelector";
import { useTranslation } from "../hooks/useTranslation";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -13,6 +14,7 @@ export default function LoginPage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const router = useRouter(); const router = useRouter();
const t = useTranslation("auth");
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -23,7 +25,7 @@ export default function LoginPage() {
await login(email, password); await login(email, password);
router.push("/"); router.push("/");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed"); setError(err instanceof Error ? err.message : t("login.loginFailed"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -37,8 +39,8 @@ export default function LoginPage() {
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>Welcome back</h1> <h1 style={styles.title}>{t("login.title")}</h1>
<p style={styles.subtitle}>Sign in to your account</p> <p style={styles.subtitle}>{t("login.subtitle")}</p>
</div> </div>
<form onSubmit={handleSubmit} style={styles.form}> <form onSubmit={handleSubmit} style={styles.form}>
@ -46,7 +48,7 @@ export default function LoginPage() {
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="email" style={styles.label}> <label htmlFor="email" style={styles.label}>
Email {t("login.email")}
</label> </label>
<input <input
id="email" id="email"
@ -54,14 +56,14 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
style={styles.input} style={styles.input}
placeholder="you@example.com" placeholder={t("login.emailPlaceholder")}
required required
/> />
</div> </div>
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="password" style={styles.label}> <label htmlFor="password" style={styles.label}>
Password {t("login.password")}
</label> </label>
<input <input
id="password" id="password"
@ -69,7 +71,7 @@ export default function LoginPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
style={styles.input} style={styles.input}
placeholder="••••••••" placeholder={t("login.passwordPlaceholder")}
required required
/> />
</div> </div>
@ -82,14 +84,14 @@ export default function LoginPage() {
}} }}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "Signing in..." : "Sign in"} {isSubmitting ? t("login.signingIn") : t("login.signIn")}
</button> </button>
</form> </form>
<p style={styles.footer}> <p style={styles.footer}>
Don&apos;t have an account?{" "} {t("login.noAccount")}{" "}
<a href="/signup" style={styles.link}> <a href="/signup" style={styles.link}>
Sign up {t("login.signUp")}
</a> </a>
</p> </p>
</div> </div>

View file

@ -4,12 +4,14 @@ import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import { useAuth } from "../../auth-context"; import { useAuth } from "../../auth-context";
import { LanguageSelector } from "../../components/LanguageSelector"; import { LanguageSelector } from "../../components/LanguageSelector";
import { useTranslation } from "../../hooks/useTranslation";
export default function SignupWithCodePage() { export default function SignupWithCodePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const code = params.code as string; const code = params.code as string;
const t = useTranslation("auth");
useEffect(() => { useEffect(() => {
// Wait for auth check to complete before redirecting // Wait for auth check to complete before redirecting
@ -40,7 +42,7 @@ export default function SignupWithCodePage() {
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}> <div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector /> <LanguageSelector />
</div> </div>
Redirecting... {t("signup.redirecting")}
</main> </main>
); );
} }

View file

@ -21,20 +21,20 @@ afterEach(() => cleanup());
test("renders signup form with title", () => { test("renders signup form with title", () => {
renderWithProviders(<SignupPage />); renderWithProviders(<SignupPage />);
// Step 1 shows "Join with Invite" title (invite code entry) // Step 1 shows "Join with Invite" title (invite code entry)
expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined(); expect(screen.getByRole("heading", { name: "Únete con Invitación" })).toBeDefined();
}); });
test("renders invite code input", () => { test("renders invite code input", () => {
renderWithProviders(<SignupPage />); renderWithProviders(<SignupPage />);
expect(screen.getByLabelText("Invite Code")).toBeDefined(); expect(screen.getByLabelText("Código de Invitación")).toBeDefined();
}); });
test("renders continue button", () => { test("renders continue button", () => {
renderWithProviders(<SignupPage />); renderWithProviders(<SignupPage />);
expect(screen.getByRole("button", { name: "Continue" })).toBeDefined(); expect(screen.getByRole("button", { name: "Continuar" })).toBeDefined();
}); });
test("renders link to login", () => { test("renders link to login", () => {
renderWithProviders(<SignupPage />); renderWithProviders(<SignupPage />);
expect(screen.getByText("Sign in")).toBeDefined(); expect(screen.getByText("Iniciar sesión")).toBeDefined();
}); });

View file

@ -6,6 +6,7 @@ import { useAuth } from "../auth-context";
import { invitesApi } from "../api"; import { invitesApi } from "../api";
import { authFormStyles as styles } from "../styles/auth-form"; import { authFormStyles as styles } from "../styles/auth-form";
import { LanguageSelector } from "../components/LanguageSelector"; import { LanguageSelector } from "../components/LanguageSelector";
import { useTranslation } from "../hooks/useTranslation";
function SignupContent() { function SignupContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -25,6 +26,7 @@ function SignupContent() {
const { user, register } = useAuth(); const { user, register } = useAuth();
const router = useRouter(); const router = useRouter();
const t = useTranslation("auth");
// Redirect if already logged in // Redirect if already logged in
useEffect(() => { useEffect(() => {
@ -51,11 +53,11 @@ function SignupContent() {
setInviteError(""); setInviteError("");
} else { } else {
setInviteValid(false); setInviteValid(false);
setInviteError(response.error || "Invalid invite code"); setInviteError(response.error || t("signup.invalidInviteCode"));
} }
} catch { } catch {
setInviteValid(false); setInviteValid(false);
setInviteError("Failed to verify invite code"); setInviteError(t("signup.failedToVerify"));
} finally { } finally {
setIsCheckingInvite(false); setIsCheckingInvite(false);
} }
@ -78,12 +80,12 @@ function SignupContent() {
setError(""); setError("");
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match"); setError(t("signup.passwordsDoNotMatch"));
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setError("Password must be at least 6 characters"); setError(t("signup.passwordTooShort"));
return; return;
} }
@ -93,7 +95,7 @@ function SignupContent() {
await register(email, password, inviteCode.trim()); await register(email, password, inviteCode.trim());
router.push("/"); router.push("/");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Registration failed"); setError(err instanceof Error ? err.message : t("signup.registrationFailed"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -114,7 +116,7 @@ function SignupContent() {
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}> <div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
Checking invite code... {t("signup.checkingInviteCode")}
</div> </div>
</div> </div>
</div> </div>
@ -132,8 +134,8 @@ function SignupContent() {
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>Join with Invite</h1> <h1 style={styles.title}>{t("signup.title")}</h1>
<p style={styles.subtitle}>Enter your invite code to get started</p> <p style={styles.subtitle}>{t("signup.subtitle")}</p>
</div> </div>
<form onSubmit={handleInviteSubmit} style={styles.form}> <form onSubmit={handleInviteSubmit} style={styles.form}>
@ -141,7 +143,7 @@ function SignupContent() {
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="inviteCode" style={styles.label}> <label htmlFor="inviteCode" style={styles.label}>
Invite Code {t("signup.inviteCode")}
</label> </label>
<input <input
id="inviteCode" id="inviteCode"
@ -153,7 +155,7 @@ function SignupContent() {
setInviteValid(null); setInviteValid(null);
}} }}
style={styles.input} style={styles.input}
placeholder="word-word-00" placeholder={t("signup.inviteCodePlaceholder")}
required required
autoFocus autoFocus
/> />
@ -165,7 +167,7 @@ function SignupContent() {
display: "block", display: "block",
}} }}
> >
Ask your inviter for this code {t("signup.inviteHint")}
</span> </span>
</div> </div>
@ -177,14 +179,14 @@ function SignupContent() {
}} }}
disabled={isCheckingInvite || !inviteCode.trim()} disabled={isCheckingInvite || !inviteCode.trim()}
> >
{isCheckingInvite ? "Checking..." : "Continue"} {isCheckingInvite ? t("signup.checking") : t("signup.continue")}
</button> </button>
</form> </form>
<p style={styles.footer}> <p style={styles.footer}>
Already have an account?{" "} {t("signup.alreadyHaveAccount")}{" "}
<a href="/login" style={styles.link}> <a href="/login" style={styles.link}>
Sign in {t("signup.signIn")}
</a> </a>
</p> </p>
</div> </div>
@ -202,9 +204,9 @@ function SignupContent() {
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>Create account</h1> <h1 style={styles.title}>{t("signup.createAccountTitle")}</h1>
<p style={styles.subtitle}> <p style={styles.subtitle}>
Using invite:{" "} {t("signup.createAccountSubtitle")}{" "}
<code <code
style={{ style={{
background: "rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.1)",
@ -223,7 +225,7 @@ function SignupContent() {
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="email" style={styles.label}> <label htmlFor="email" style={styles.label}>
Email {t("signup.email")}
</label> </label>
<input <input
id="email" id="email"
@ -231,7 +233,7 @@ function SignupContent() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
style={styles.input} style={styles.input}
placeholder="you@example.com" placeholder={t("signup.emailPlaceholder")}
required required
autoFocus autoFocus
/> />
@ -239,7 +241,7 @@ function SignupContent() {
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="password" style={styles.label}> <label htmlFor="password" style={styles.label}>
Password {t("signup.password")}
</label> </label>
<input <input
id="password" id="password"
@ -247,14 +249,14 @@ function SignupContent() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
style={styles.input} style={styles.input}
placeholder="••••••••" placeholder={t("signup.passwordPlaceholder")}
required required
/> />
</div> </div>
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="confirmPassword" style={styles.label}> <label htmlFor="confirmPassword" style={styles.label}>
Confirm Password {t("signup.confirmPassword")}
</label> </label>
<input <input
id="confirmPassword" id="confirmPassword"
@ -262,7 +264,7 @@ function SignupContent() {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
style={styles.input} style={styles.input}
placeholder="••••••••" placeholder={t("signup.confirmPasswordPlaceholder")}
required required
/> />
</div> </div>
@ -275,7 +277,7 @@ function SignupContent() {
}} }}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "Creating account..." : "Create account"} {isSubmitting ? t("signup.creatingAccount") : t("signup.createAccount")}
</button> </button>
</form> </form>
@ -293,7 +295,7 @@ function SignupContent() {
padding: 0, padding: 0,
}} }}
> >
Use a different invite code {t("signup.useDifferentCode")}
</button> </button>
</p> </p>
</div> </div>

View file

@ -0,0 +1,44 @@
{
"login": {
"title": "Benvingut de nou",
"subtitle": "Inicia sessió al teu compte",
"email": "Correu electrònic",
"password": "Contrasenya",
"emailPlaceholder": "tu@exemple.com",
"passwordPlaceholder": "••••••••",
"signIn": "Iniciar sessió",
"signingIn": "Iniciant sessió...",
"noAccount": "No tens un compte?",
"signUp": "Registra't",
"loginFailed": "Error en iniciar sessió"
},
"signup": {
"title": "Uneix-te amb Invitació",
"subtitle": "Introdueix el teu codi d'invitació per començar",
"inviteCode": "Codi d'Invitació",
"inviteCodePlaceholder": "paraula-paraula-00",
"inviteHint": "Demana aquest codi al teu convidant",
"checking": "Comprovant...",
"continue": "Continuar",
"checkingInviteCode": "Comprovant codi d'invitació...",
"createAccount": "Crear compte",
"createAccountTitle": "Crear compte",
"createAccountSubtitle": "Utilitzant invitació:",
"email": "Correu electrònic",
"emailPlaceholder": "tu@exemple.com",
"password": "Contrasenya",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirmar Contrasenya",
"confirmPasswordPlaceholder": "••••••••",
"creatingAccount": "Creant compte...",
"alreadyHaveAccount": "Ja tens un compte?",
"signIn": "Iniciar sessió",
"useDifferentCode": "Utilitzar un codi d'invitació diferent",
"redirecting": "Redirigint...",
"passwordsDoNotMatch": "Les contrasenyes no coincideixen",
"passwordTooShort": "La contrasenya ha de tenir almenys 6 caràcters",
"invalidInviteCode": "Codi d'invitació invàlid",
"failedToVerify": "Error en verificar codi d'invitació",
"registrationFailed": "Error en registrar-se"
}
}

View file

@ -0,0 +1,44 @@
{
"login": {
"title": "Welcome back",
"subtitle": "Sign in to your account",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
"passwordPlaceholder": "••••••••",
"signIn": "Sign in",
"signingIn": "Signing in...",
"noAccount": "Don't have an account?",
"signUp": "Sign up",
"loginFailed": "Login failed"
},
"signup": {
"title": "Join with Invite",
"subtitle": "Enter your invite code to get started",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "word-word-00",
"inviteHint": "Ask your inviter for this code",
"checking": "Checking...",
"continue": "Continue",
"checkingInviteCode": "Checking invite code...",
"createAccount": "Create account",
"createAccountTitle": "Create account",
"createAccountSubtitle": "Using invite:",
"email": "Email",
"emailPlaceholder": "you@example.com",
"password": "Password",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "••••••••",
"creatingAccount": "Creating account...",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in",
"useDifferentCode": "Use a different invite code",
"redirecting": "Redirecting...",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"invalidInviteCode": "Invalid invite code",
"failedToVerify": "Failed to verify invite code",
"registrationFailed": "Registration failed"
}
}

View file

@ -0,0 +1,44 @@
{
"login": {
"title": "Bienvenido de nuevo",
"subtitle": "Inicia sesión en tu cuenta",
"email": "Correo electrónico",
"password": "Contraseña",
"emailPlaceholder": "tu@ejemplo.com",
"passwordPlaceholder": "••••••••",
"signIn": "Iniciar sesión",
"signingIn": "Iniciando sesión...",
"noAccount": "¿No tienes una cuenta?",
"signUp": "Regístrate",
"loginFailed": "Error al iniciar sesión"
},
"signup": {
"title": "Únete con Invitación",
"subtitle": "Ingresa tu código de invitación para comenzar",
"inviteCode": "Código de Invitación",
"inviteCodePlaceholder": "palabra-palabra-00",
"inviteHint": "Pide este código a tu invitador",
"checking": "Verificando...",
"continue": "Continuar",
"checkingInviteCode": "Verificando código de invitación...",
"createAccount": "Crear cuenta",
"createAccountTitle": "Crear cuenta",
"createAccountSubtitle": "Usando invitación:",
"email": "Correo electrónico",
"emailPlaceholder": "tu@ejemplo.com",
"password": "Contraseña",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirmar Contraseña",
"confirmPasswordPlaceholder": "••••••••",
"creatingAccount": "Creando cuenta...",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"signIn": "Iniciar sesión",
"useDifferentCode": "Usar un código de invitación diferente",
"redirecting": "Redirigiendo...",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 6 caracteres",
"invalidInviteCode": "Código de invitación inválido",
"failedToVerify": "Error al verificar código de invitación",
"registrationFailed": "Error al registrarse"
}
}