diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 666db844..8b4794d2 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -34,9 +34,11 @@ "axios": "^1.16.0", "clsx": "^2.1.1", "dompurify": "^3.2.7", + "i18next": "^24.2.3", "lucide-react": "^0.563.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^15.7.4", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", @@ -89,6 +91,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -250,6 +253,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -885,6 +897,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" @@ -2102,6 +2115,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2363,6 +2377,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2470,6 +2485,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.3.tgz", "integrity": "sha512-inbQSusJad7H0T++L1APg/anfL5d15cNGp2YG3vwo6TQr71nn2c9pepvmz3xuAIt8eygZDRba+4GT/COP1f9QA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2575,6 +2591,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.3.tgz", "integrity": "sha512-JKmWAogM/LX9ZJmXJQalpcR77wWVtVXdRFgvHGsFomW9WFhZqcnIEDWR2sbpZHWtu8dml6eBQGhdLppJmxeFfA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2601,6 +2618,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2615,6 +2633,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2708,6 +2727,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.3.tgz", "integrity": "sha512-m2c+5gDj2vW7UI1J4JHCKehQUVE12qBhgF+DC+WEWUU8ZrFNf5OEYWQHDNsopa5RRpilfKfhPNbMtXgvGOsk6g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2821,6 +2841,7 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2830,6 +2851,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2839,6 +2861,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2901,6 +2924,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -3148,6 +3172,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3272,6 +3297,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3791,6 +3817,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4289,6 +4316,47 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5028,6 +5096,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5201,6 +5270,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -5230,6 +5300,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -5278,6 +5349,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5317,6 +5389,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5326,6 +5399,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5333,6 +5407,32 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -5345,6 +5445,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5435,7 +5536,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5685,8 +5787,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5809,6 +5912,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5878,6 +5982,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -5936,6 +6049,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/web/package.json b/apps/web/package.json index a51c200d..601feb13 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,9 +40,11 @@ "axios": "^1.16.0", "clsx": "^2.1.1", "dompurify": "^3.2.7", + "i18next": "^24.2.3", "lucide-react": "^0.563.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^15.7.4", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", diff --git a/apps/web/src/components/LanguageSwitcher.tsx b/apps/web/src/components/LanguageSwitcher.tsx new file mode 100644 index 00000000..3ebe502e --- /dev/null +++ b/apps/web/src/components/LanguageSwitcher.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { SUPPORTED_LANGUAGES, setLanguage, type LanguageCode } from '../i18n'; +import { cn } from '../lib/utils'; + +interface LanguageSwitcherProps { + className?: string; +} + +/** + * Compact UI-language selector (issue #32). Persists the choice to localStorage + * via setLanguage so it survives reloads and applies app-wide immediately. + */ +export function LanguageSwitcher({ className }: LanguageSwitcherProps) { + const { t, i18n } = useTranslation('common'); + const current = i18n.resolvedLanguage ?? i18n.language; + + return ( + + ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 00000000..f6fed5ed --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,50 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import enAuth from './locales/en/auth.json'; +import enCommon from './locales/en/common.json'; +import esAuth from './locales/es/auth.json'; +import esCommon from './locales/es/common.json'; + +/** localStorage key holding the user's chosen UI language. */ +export const LANGUAGE_STORAGE_KEY = 'devlane.language'; + +/** Languages the UI currently ships translations for. */ +export const SUPPORTED_LANGUAGES = [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Español' }, +] as const; + +export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code']; + +const resources = { + en: { auth: enAuth, common: enCommon }, + es: { auth: esAuth, common: esCommon }, +} as const; + +function initialLanguage(): string { + if (typeof window === 'undefined') return 'en'; + const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + if (stored && SUPPORTED_LANGUAGES.some((l) => l.code === stored)) return stored; + return 'en'; +} + +void i18n.use(initReactI18next).init({ + resources, + lng: initialLanguage(), + fallbackLng: 'en', + ns: ['common', 'auth'], + defaultNS: 'common', + interpolation: { escapeValue: false }, // React already escapes + returnNull: false, +}); + +/** Persist and apply a language change. */ +export function setLanguage(code: LanguageCode) { + if (typeof window !== 'undefined') { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, code); + } + void i18n.changeLanguage(code); +} + +export default i18n; diff --git a/apps/web/src/i18n/locales/en/auth.json b/apps/web/src/i18n/locales/en/auth.json new file mode 100644 index 00000000..da218e11 --- /dev/null +++ b/apps/web/src/i18n/locales/en/auth.json @@ -0,0 +1,34 @@ +{ + "getStartedTitle": "Get started with Devlane", + "checkEmailTitle": "Check your email", + "welcomeBack": "Welcome back!", + "enterEmailSubtitle": "Enter your email to continue.", + "codeSentSubtitle": "We sent a 6-digit code to your inbox. It expires in 10 minutes.", + "enterPassword": "Enter your password to sign in.", + "createAccountHint": "After you continue, you can use your password or choose a one-time email code instead.", + "continueWithGoogle": "Continue with Google", + "continueWithGitHub": "Continue with GitHub", + "continueWithGitLab": "Continue with GitLab", + "or": "or", + "emailLabel": "Email", + "emailPlaceholder": "you@example.com", + "continueWithEmail": "Continue with email", + "checking": "Checking…", + "passwordLabel": "Password", + "passwordPlaceholder": "Enter password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "forgotPassword": "Forgot your password?", + "resetPasswordNoSmtp": "To reset your password, ask your administrator to configure SMTP.", + "useEmailCodeInstead": "Sign in with email code instead", + "signIn": "Sign in", + "signingIn": "Signing in…", + "dontHaveAccount": "Don't have an account?", + "signUp": "Sign up", + "sixDigitCodeLabel": "6-digit code", + "codePlaceholder": "000000", + "continue": "Continue", + "verifying": "Verifying…", + "resendCode": "Resend code", + "usePasswordInstead": "Use password instead" +} diff --git a/apps/web/src/i18n/locales/en/common.json b/apps/web/src/i18n/locales/en/common.json new file mode 100644 index 00000000..dabc2c96 --- /dev/null +++ b/apps/web/src/i18n/locales/en/common.json @@ -0,0 +1,7 @@ +{ + "language": "Language", + "languageName": { + "en": "English", + "es": "Español" + } +} diff --git a/apps/web/src/i18n/locales/es/auth.json b/apps/web/src/i18n/locales/es/auth.json new file mode 100644 index 00000000..f3baaf0f --- /dev/null +++ b/apps/web/src/i18n/locales/es/auth.json @@ -0,0 +1,34 @@ +{ + "getStartedTitle": "Comienza con Devlane", + "checkEmailTitle": "Revisa tu correo", + "welcomeBack": "¡Bienvenido de nuevo!", + "enterEmailSubtitle": "Introduce tu correo para continuar.", + "codeSentSubtitle": "Enviamos un código de 6 dígitos a tu bandeja de entrada. Caduca en 10 minutos.", + "enterPassword": "Introduce tu contraseña para iniciar sesión.", + "createAccountHint": "Después de continuar, puedes usar tu contraseña o elegir un código de un solo uso por correo.", + "continueWithGoogle": "Continuar con Google", + "continueWithGitHub": "Continuar con GitHub", + "continueWithGitLab": "Continuar con GitLab", + "or": "o", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "tu@ejemplo.com", + "continueWithEmail": "Continuar con el correo", + "checking": "Comprobando…", + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Introduce la contraseña", + "showPassword": "Mostrar contraseña", + "hidePassword": "Ocultar contraseña", + "forgotPassword": "¿Olvidaste tu contraseña?", + "resetPasswordNoSmtp": "Para restablecer tu contraseña, pide a tu administrador que configure SMTP.", + "useEmailCodeInstead": "Iniciar sesión con código por correo", + "signIn": "Iniciar sesión", + "signingIn": "Iniciando sesión…", + "dontHaveAccount": "¿No tienes una cuenta?", + "signUp": "Regístrate", + "sixDigitCodeLabel": "Código de 6 dígitos", + "codePlaceholder": "000000", + "continue": "Continuar", + "verifying": "Verificando…", + "resendCode": "Reenviar código", + "usePasswordInstead": "Usar contraseña" +} diff --git a/apps/web/src/i18n/locales/es/common.json b/apps/web/src/i18n/locales/es/common.json new file mode 100644 index 00000000..8c4ce722 --- /dev/null +++ b/apps/web/src/i18n/locales/es/common.json @@ -0,0 +1,7 @@ +{ + "language": "Idioma", + "languageName": { + "en": "English", + "es": "Español" + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2239905c..3d55158d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; +import './i18n'; import App from './App.tsx'; createRoot(document.getElementById('root')!).render( diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 6c0e6d6a..ae66771a 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -1,11 +1,13 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { Button, Input } from '../components/ui'; import { useAuth } from '../contexts/AuthContext'; import { authService } from '../services/authService'; import { API_BASE, getApiErrorMessage } from '../api/client'; import { Eye, EyeOff, CircleAlert } from 'lucide-react'; import { AuthPageShell } from '../components/auth/AuthPageShell'; +import { LanguageSwitcher } from '../components/LanguageSwitcher'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; type AuthStep = 'email' | 'password' | 'code'; @@ -14,6 +16,7 @@ type AuthMode = 'sign-in' | 'sign-up'; export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); + const { t } = useTranslation('auth'); const { login, refreshUser } = useAuth(); const state = location.state as { @@ -212,16 +215,16 @@ export function LoginPage() { }, []); const title = useMemo(() => { - if (step === 'email') return 'Get started with Devlane'; - if (step === 'code') return 'Check your email'; - return 'Welcome back!'; - }, [step]); + if (step === 'email') return t('getStartedTitle'); + if (step === 'code') return t('checkEmailTitle'); + return t('welcomeBack'); + }, [step, t]); const subtitle = useMemo(() => { - if (step === 'email') return 'Enter your email to continue.'; - if (step === 'code') return 'We sent a 6-digit code to your inbox. It expires in 10 minutes.'; - return 'Enter your password to sign in.'; - }, [step]); + if (step === 'email') return t('enterEmailSubtitle'); + if (step === 'code') return t('codeSentSubtitle'); + return t('enterPassword'); + }, [step, t]); return ( @@ -229,9 +232,7 @@ export function LoginPage() {

{title}

{subtitle}

{step === 'email' && isPasswordEnabled && canUseMagicCode && ( -

- After you continue, you can use your password or choose a one-time email code instead. -

+

{t('createAccountHint')}

)} {error && ( @@ -269,7 +270,7 @@ export function LoginPage() { fill="#EA4335" /> - Continue with Google + {t('continueWithGoogle')} )} {oauthProviders.github && ( @@ -281,7 +282,7 @@ export function LoginPage() { - Continue with GitHub + {t('continueWithGitHub')} )} {oauthProviders.gitlab && ( @@ -293,7 +294,7 @@ export function LoginPage() { - Continue with GitLab + {t('continueWithGitLab')} )}
@@ -301,24 +302,24 @@ export function LoginPage() {
- or + {t('or')}
)}
setEmail(e.target.value)} - placeholder="you@example.com" + placeholder={t('emailPlaceholder')} required autoComplete="email" autoFocus />
@@ -339,11 +340,11 @@ export function LoginPage() {
setPassword(e.target.value)} - placeholder="Enter password" + placeholder={t('passwordPlaceholder')} required autoComplete="current-password" autoFocus @@ -352,7 +353,7 @@ export function LoginPage() { type="button" className="absolute top-[2.1rem] right-3 text-(--txt-tertiary) hover:text-(--txt-primary)" onClick={() => setShowPassword((v) => !v)} - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? t('hidePassword') : t('showPassword')} aria-pressed={showPassword} > {showPassword ? : } @@ -367,12 +368,10 @@ export function LoginPage() { state={{ email }} className="text-xs text-(--txt-accent) hover:underline" > - Forgot your password? + {t('forgotPassword')} ) : ( - - To reset your password, ask your administrator to configure SMTP. - + {t('resetPasswordNoSmtp')} )}
)} @@ -384,19 +383,19 @@ export function LoginPage() { disabled={isSubmitting} className="w-full text-center text-xs font-medium text-(--txt-accent) hover:underline disabled:opacity-50" > - Sign in with email code instead + {t('useEmailCodeInstead')} )} {(allowSignup || !!inviteToken) && (

- {"Don't have an account? "} + {t('dontHaveAccount')}{' '} - Sign up + {t('signUp')}

)} @@ -417,7 +416,7 @@ export function LoginPage() { {isPasswordEnabled && ( @@ -448,11 +447,15 @@ export function LoginPage() { onClick={goBackToPassword} className="w-full text-center text-xs text-(--txt-tertiary) hover:text-(--txt-primary)" > - Use password instead + {t('usePasswordInstead')} )} )} + +
+ +
); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index a9b5a59c..5996e39d 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -10,6 +10,7 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force",