/ frontend / src / app / i18n.js
i18n.js
  1  // RESTai internationalization setup.
  2  //
  3  // Language resolution order:
  4  //   1. Authenticated user.options.language (applied from JWTAuthContext
  5  //      once whoami resolves — see `applyLanguage` below)
  6  //   2. localStorage `restai_language` (set by the topbar picker and by
  7  //      applyLanguage for pre-login continuity across sessions)
  8  //   3. `navigator.language` via the language-detector
  9  //   4. `en` (fallback + default)
 10  //
 11  // Locale files live at ./locales/<code>/translation.json. Keep the key
 12  // tree scoped by page/area (e.g. `dashboard.cardProjects`) so hunting
 13  // strings during translation is easy.
 14  
 15  import i18n from "i18next";
 16  import { initReactI18next } from "react-i18next";
 17  import LanguageDetector from "i18next-browser-languagedetector";
 18  
 19  import en from "./locales/en/translation.json";
 20  import ptPT from "./locales/pt-PT/translation.json";
 21  import zhCN from "./locales/zh-CN/translation.json";
 22  import da from "./locales/da/translation.json";
 23  import de from "./locales/de/translation.json";
 24  import es from "./locales/es/translation.json";
 25  import fr from "./locales/fr/translation.json";
 26  import it from "./locales/it/translation.json";
 27  import nl from "./locales/nl/translation.json";
 28  
 29  export const SUPPORTED_LANGUAGES = [
 30    { code: "en",    label: "English",                nativeLabel: "English" },
 31    { code: "da",    label: "Danish",                 nativeLabel: "Dansk" },
 32    { code: "de",    label: "German",                 nativeLabel: "Deutsch" },
 33    { code: "es",    label: "Spanish",                nativeLabel: "Español" },
 34    { code: "fr",    label: "French",                 nativeLabel: "Français" },
 35    { code: "it",    label: "Italian",                nativeLabel: "Italiano" },
 36    { code: "nl",    label: "Dutch",                  nativeLabel: "Nederlands" },
 37    { code: "pt-PT", label: "Portuguese (Portugal)",  nativeLabel: "Português" },
 38    { code: "zh-CN", label: "Chinese (Simplified)",   nativeLabel: "中文" },
 39  ];
 40  
 41  // Resolve the initial language BEFORE init() so i18next builds the
 42  // fallback chain (`languages` array) against a supported code from
 43  // the start. Without this, a browser locale like "en-US" lands in
 44  // `i18n.languages = ["en"]` and later `changeLanguage("pt-PT")` fails
 45  // to rebuild the chain — the translator keeps resolving through "en"
 46  // and returns English strings despite i18n.language having changed.
 47  const SUPPORTED_CODES = SUPPORTED_LANGUAGES.map((l) => l.code);
 48  function pickInitialLanguage() {
 49    try {
 50      const fromLs = typeof localStorage !== "undefined" && localStorage.getItem("restai_language");
 51      if (fromLs && SUPPORTED_CODES.includes(fromLs)) return fromLs;
 52    } catch {}
 53    const nav = typeof navigator !== "undefined" && (navigator.language || "");
 54    if (nav) {
 55      if (SUPPORTED_CODES.includes(nav)) return nav;
 56      const base = nav.split("-")[0];
 57      const match = SUPPORTED_CODES.find((c) => c.split("-")[0] === base);
 58      if (match) return match;
 59    }
 60    return "en";
 61  }
 62  
 63  i18n
 64    .use(LanguageDetector)
 65    .use(initReactI18next)
 66    .init({
 67      resources: {
 68        "en":    { translation: en },
 69        "da":    { translation: da },
 70        "de":    { translation: de },
 71        "es":    { translation: es },
 72        "fr":    { translation: fr },
 73        "it":    { translation: it },
 74        "nl":    { translation: nl },
 75        "pt-PT": { translation: ptPT },
 76        "zh-CN": { translation: zhCN },
 77      },
 78      lng: pickInitialLanguage(),
 79      fallbackLng: "en",
 80      supportedLngs: SUPPORTED_CODES,
 81      // DO NOT enable nonExplicitSupportedLngs — with supportedLngs
 82      // containing only full region codes (pt-PT, zh-CN), it strips
 83      // "pt-PT" → "pt" before the supportedLngs check and rejects it,
 84      // then toResolveHierarchy collapses to just the fallback "en" and
 85      // every t() returns English no matter what i18n.language says.
 86      // Base-code browser locales (eg. "pt") are already normalized in
 87      // pickInitialLanguage() above, so this flag is redundant anyway.
 88      load: "currentOnly",
 89      interpolation: { escapeValue: false },  // React already escapes
 90      detection: {
 91        order: ["localStorage", "navigator"],
 92        lookupLocalStorage: "restai_language",
 93        caches: ["localStorage"],
 94      },
 95      react: {
 96        useSuspense: false,
 97        bindI18n: "languageChanged loaded",
 98      },
 99    });
100  
101  /**
102   * Apply a language choice end-to-end: switch i18next, persist to
103   * localStorage so the pre-login screens pick it up too. Call this
104   * from (a) the topbar picker onChange and (b) JWTAuthContext after
105   * whoami resolves with a user who has `options.language` set.
106   */
107  export function applyLanguage(lang) {
108    if (!lang) return;
109    try { localStorage.setItem("restai_language", lang); } catch {}
110    if (i18n.language !== lang) i18n.changeLanguage(lang);
111  }
112  
113  export default i18n;