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 enExchange from "../../locales/en/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 = {
es: { common: esCommon, navigation: esNavigation, exchange: esExchange },
en: { common: enCommon, navigation: enNavigation, exchange: enExchange },
ca: { common: caCommon, navigation: caNavigation, exchange: caExchange },
es: { common: esCommon, navigation: esNavigation, exchange: esExchange, auth: esAuth },
en: { common: enCommon, navigation: enNavigation, exchange: enExchange, auth: enAuth },
ca: { common: caCommon, navigation: caNavigation, exchange: caExchange, auth: caAuth },
};
interface IntlProviderProps {

View file

@ -19,21 +19,21 @@ afterEach(() => cleanup());
test("renders login form with title", () => {
renderWithProviders(<LoginPage />);
expect(screen.getByText("Welcome back")).toBeDefined();
expect(screen.getByText("Bienvenido de nuevo")).toBeDefined();
});
test("renders email and password inputs", () => {
renderWithProviders(<LoginPage />);
expect(screen.getByLabelText("Email")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined();
expect(screen.getByLabelText("Correo electrónico")).toBeDefined();
expect(screen.getByLabelText("Contraseña")).toBeDefined();
});
test("renders sign in button", () => {
renderWithProviders(<LoginPage />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
expect(screen.getByRole("button", { name: "Iniciar sesión" })).toBeDefined();
});
test("renders link to signup", () => {
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 { authFormStyles as styles } from "../styles/auth-form";
import { LanguageSelector } from "../components/LanguageSelector";
import { useTranslation } from "../hooks/useTranslation";
export default function LoginPage() {
const [email, setEmail] = useState("");
@ -13,6 +14,7 @@ export default function LoginPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
const t = useTranslation("auth");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -23,7 +25,7 @@ export default function LoginPage() {
await login(email, password);
router.push("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
setError(err instanceof Error ? err.message : t("login.loginFailed"));
} finally {
setIsSubmitting(false);
}
@ -37,8 +39,8 @@ export default function LoginPage() {
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>
<h1 style={styles.title}>Welcome back</h1>
<p style={styles.subtitle}>Sign in to your account</p>
<h1 style={styles.title}>{t("login.title")}</h1>
<p style={styles.subtitle}>{t("login.subtitle")}</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
@ -46,7 +48,7 @@ export default function LoginPage() {
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>
Email
{t("login.email")}
</label>
<input
id="email"
@ -54,14 +56,14 @@ export default function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="you@example.com"
placeholder={t("login.emailPlaceholder")}
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>
Password
{t("login.password")}
</label>
<input
id="password"
@ -69,7 +71,7 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
placeholder={t("login.passwordPlaceholder")}
required
/>
</div>
@ -82,14 +84,14 @@ export default function LoginPage() {
}}
disabled={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
</button>
</form>
<p style={styles.footer}>
Don&apos;t have an account?{" "}
{t("login.noAccount")}{" "}
<a href="/signup" style={styles.link}>
Sign up
{t("login.signUp")}
</a>
</p>
</div>

View file

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

View file

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