diff --git a/app/i18n/LangAttribute.tsx b/app/i18n/LangAttribute.tsx new file mode 100644 index 00000000..c18ef1c1 --- /dev/null +++ b/app/i18n/LangAttribute.tsx @@ -0,0 +1,35 @@ +/** + * LangAttribute — Client component that keeps the document's `lang` attribute + * in sync with the user's active language preference. + * + * Mount this once, near the top of the component tree (e.g. inside the root + * `` wrapper). It renders nothing to the DOM; its only effect is + * setting `document.documentElement.lang` so that: + * - Screen readers announce content in the correct language. + * - Browser translate tools activate appropriately. + * - CSS `:lang()` selectors work. + * + * The `lang="en"` attribute on `` in layout.tsx remains as the SSR + * default; this component corrects it on the client once the stored preference + * is hydrated from localStorage. + */ +"use client"; + +import { useEffect } from "react"; +import { useLanguage } from "@/hooks/useLanguage"; + +/** + * Invisible client component — updates `` whenever the user's + * language preference changes. + */ +export function LangAttribute() { + const { languageCode, isReady } = useLanguage(); + + useEffect(() => { + if (!isReady) return; + document.documentElement.lang = languageCode; + }, [languageCode, isReady]); + + // Renders nothing — side-effects only. + return null; +} diff --git a/app/i18n/__tests__/t.test.ts b/app/i18n/__tests__/t.test.ts new file mode 100644 index 00000000..74846a13 --- /dev/null +++ b/app/i18n/__tests__/t.test.ts @@ -0,0 +1,225 @@ +/** + * Tests for the i18n translation layer. + * + * Covers: + * - `interpolate()` — placeholder substitution edge cases. + * - `createTranslator()` — translation lookup, fallback, and warnings. + * - `getTranslator()` — locale selection with and without explicit locale. + * - `getMessages()` — catalog loader and fallback to English. + * - English catalog — structural integrity checks. + * - `AVAILABLE_LOCALES` — at minimum includes "en". + */ + +import { + interpolate, + createTranslator, + getTranslator, + getMessages, + AVAILABLE_LOCALES, + defaultMessages, +} from "@/app/i18n"; + +// ── interpolate ─────────────────────────────────────────────────────────────── + +describe("interpolate", () => { + it("returns the message unchanged when no vars are provided", () => { + expect(interpolate("Hello, world!")).toBe("Hello, world!"); + }); + + it("replaces a single placeholder", () => { + expect(interpolate("Welcome, {name}!", { name: "Alice" })).toBe( + "Welcome, Alice!" + ); + }); + + it("replaces multiple distinct placeholders", () => { + const result = interpolate("Hello {first} {last}!", { + first: "John", + last: "Doe", + }); + expect(result).toBe("Hello John Doe!"); + }); + + it("replaces the same placeholder appearing multiple times", () => { + expect(interpolate("{x} + {x} = {y}", { x: "1", y: "2" })).toBe( + "1 + 1 = 2" + ); + }); + + it("leaves un-matched placeholders intact", () => { + expect(interpolate("Hello {name}!", {})).toBe("Hello {name}!"); + }); + + it("coerces numeric values to strings", () => { + expect(interpolate("{count} participants", { count: 42 })).toBe( + "42 participants" + ); + }); + + it("returns empty string when message is empty", () => { + expect(interpolate("", { name: "x" })).toBe(""); + }); +}); + +// ── createTranslator ────────────────────────────────────────────────────────── + +describe("createTranslator", () => { + const messages = { + "nav.dashboard": "Dashboard", + "greeting.welcome": "Welcome, {name}!", + "count.items": "{count} items", + }; + const t = createTranslator(messages); + + it("returns the translated string for a known key", () => { + expect(t("nav.dashboard")).toBe("Dashboard"); + }); + + it("interpolates variables into the message", () => { + expect(t("greeting.welcome", { name: "Bob" })).toBe("Welcome, Bob!"); + }); + + it("returns the key itself when the key is missing", () => { + expect(t("unknown.key")).toBe("unknown.key"); + }); + + it("logs a warning in development for a missing key", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const originalEnv = process.env.NODE_ENV; + // Jest runs in 'test' which is non-production, so the warning fires. + t("definitely.missing"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("definitely.missing") + ); + warn.mockRestore(); + // Restore env in case it was changed + Object.defineProperty(process.env, "NODE_ENV", { value: originalEnv, configurable: true }); + }); + + it("works with no variables even when the message has no placeholders", () => { + expect(t("nav.dashboard", {})).toBe("Dashboard"); + }); + + it("handles numeric interpolation values", () => { + expect(t("count.items", { count: 5 })).toBe("5 items"); + }); +}); + +// ── getMessages ─────────────────────────────────────────────────────────────── + +describe("getMessages", () => { + it("returns the English catalog for 'en'", () => { + const msgs = getMessages("en"); + expect(typeof msgs).toBe("object"); + expect(msgs["nav.dashboard"]).toBe("Dashboard"); + }); + + it("falls back to English for an unregistered locale", () => { + const msgs = getMessages("xx-UNKNOWN"); + expect(msgs["nav.dashboard"]).toBe("Dashboard"); + }); + + it("returns the same reference on repeated calls for the same locale", () => { + expect(getMessages("en")).toBe(getMessages("en")); + }); +}); + +// ── getTranslator ───────────────────────────────────────────────────────────── + +describe("getTranslator", () => { + it("returns a working translator for the English locale", () => { + const t = getTranslator("en"); + expect(typeof t).toBe("function"); + expect(t("nav.dashboard")).toBe("Dashboard"); + }); + + it("falls back to English for an unknown locale", () => { + const t = getTranslator("zz"); + expect(t("nav.dashboard")).toBe("Dashboard"); + }); + + it("interpolates variables correctly", () => { + const t = getTranslator("en"); + expect(t("dashboard.welcome", { name: "Alice" })).toBe( + "Welcome back, Alice!" + ); + }); +}); + +// ── English catalog structural checks ──────────────────────────────────────── + +describe("defaultMessages (English catalog)", () => { + it("contains at least 50 translation keys", () => { + expect(Object.keys(defaultMessages).length).toBeGreaterThanOrEqual(50); + }); + + it("every key is a non-empty string", () => { + for (const key of Object.keys(defaultMessages)) { + expect(typeof key).toBe("string"); + expect(key.length).toBeGreaterThan(0); + } + }); + + it("every value is a non-empty string", () => { + for (const [key, value] of Object.entries(defaultMessages)) { + expect(typeof value).toBe("string"); + expect(value.length).toBeGreaterThan(0, `Key "${key}" has an empty value`); + } + }); + + it("all keys follow the dot-separated namespace convention", () => { + for (const key of Object.keys(defaultMessages)) { + expect(key).toMatch(/^[a-z0-9_]+(\.[a-z0-9_.]+)+$/); + } + }); + + it("includes navigation keys", () => { + expect(defaultMessages["nav.dashboard"]).toBeDefined(); + expect(defaultMessages["nav.settings"]).toBeDefined(); + }); + + it("includes wallet keys", () => { + expect(defaultMessages["wallet.connect_title"]).toBeDefined(); + expect(defaultMessages["wallet.connecting"]).toBeDefined(); + }); + + it("includes accessibility keys", () => { + expect(defaultMessages["a11y.close_dialog"]).toBeDefined(); + expect(defaultMessages["a11y.loading"]).toBeDefined(); + }); + + it("includes error and feedback keys", () => { + expect(defaultMessages["error.generic"]).toBeDefined(); + expect(defaultMessages["feedback.copied"]).toBeDefined(); + }); + + it("the welcome message contains a {name} placeholder", () => { + expect(defaultMessages["dashboard.welcome"]).toContain("{name}"); + }); + + it("placeholder variables use the {word} format only", () => { + const placeholderRe = /\{[^}]+\}/g; + const identifierRe = /^\{\w+\}$/; + for (const [key, value] of Object.entries(defaultMessages)) { + const matches = value.match(placeholderRe) ?? []; + for (const match of matches) { + expect(match).toMatch(identifierRe, `Invalid placeholder "${match}" in key "${key}"`); + } + } + }); +}); + +// ── AVAILABLE_LOCALES ───────────────────────────────────────────────────────── + +describe("AVAILABLE_LOCALES", () => { + it("includes 'en'", () => { + expect(AVAILABLE_LOCALES).toContain("en"); + }); + + it("is an array of non-empty strings", () => { + for (const code of AVAILABLE_LOCALES) { + expect(typeof code).toBe("string"); + expect(code.length).toBeGreaterThan(0); + } + }); +}); diff --git a/app/i18n/index.ts b/app/i18n/index.ts index c88e3fa4..05cbc6ca 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -1,11 +1,30 @@ /** - * i18n — Language preference module + * i18n — Localization foundation * - * Provides the list of supported UI languages, a localStorage-backed hook for - * reading/writing the active language, and a thin helper layer so other modules - * can access the current language without mounting a React component. + * Public surface of the i18n module. Consumers should import from here rather + * than from sub-modules directly so that internal structure can change freely. + * + * Exports: + * - Language types and the list of supported UI languages. + * - localStorage-backed helpers for reading/writing the active language. + * - Message catalog types (`Messages`, `InterpolationValues`). + * - `getMessages()` — returns the flat key→string catalog for a locale. + * - `createTranslator()` — builds a `t()` function from a catalog. + * - `getTranslator()` — server-safe `t()` for the current/given locale. + * - `useTranslation()` — React hook; returns a reactive `t()` function. + * - `interpolate()` — standalone `{variable}` interpolation helper. */ +// ── Re-export translation utilities ────────────────────────────────────────── +export type { Messages, InterpolationValues } from "./types"; +export { getMessages, AVAILABLE_LOCALES, defaultMessages } from "./messages/index"; +export { + interpolate, + createTranslator, + getTranslator, + useTranslation, +} from "./t"; + export type Language = { /** BCP-47 language tag used as the stored key and value. */ code: string; diff --git a/app/i18n/messages/en.ts b/app/i18n/messages/en.ts new file mode 100644 index 00000000..1db92297 --- /dev/null +++ b/app/i18n/messages/en.ts @@ -0,0 +1,175 @@ +/** + * English (en) — Base locale message catalog. + * + * This is the source-of-truth for all translatable strings in Predictify. + * Every other locale file must supply a value for every key defined here. + * + * Naming conventions: + * - Keys use dot-separated namespaces: `"."` + * - Use `{variable}` for runtime-interpolated values. + * - Keep values short and imperative where possible (UI copy). + * - Do NOT store HTML in values — compose JSX around translated strings. + */ +import type { Messages } from "../types"; + +const en: Messages = { + // ── Navigation ────────────────────────────────────────────────────────────── + "nav.dashboard": "Dashboard", + "nav.events": "Events", + "nav.predictions": "My Predictions", + "nav.bets": "Active Bets", + "nav.leaderboard": "Leaderboard", + "nav.finances": "Finances", + "nav.disputes": "Disputes", + "nav.profile": "Profile", + "nav.settings": "Settings", + "nav.help": "Help", + + // ── Common actions ─────────────────────────────────────────────────────────── + "action.connect_wallet": "Connect Wallet", + "action.disconnect": "Disconnect", + "action.confirm": "Confirm", + "action.cancel": "Cancel", + "action.save": "Save", + "action.close": "Close", + "action.back": "Back", + "action.next": "Next", + "action.submit": "Submit", + "action.retry": "Try Again", + "action.copy": "Copy", + "action.share": "Share", + "action.view_all": "View All", + "action.load_more": "Load More", + + // ── Common labels ──────────────────────────────────────────────────────────── + "label.loading": "Loading…", + "label.error": "Something went wrong", + "label.empty": "Nothing here yet", + "label.required": "Required", + "label.optional": "Optional", + "label.search": "Search", + "label.filter": "Filter", + "label.sort": "Sort", + "label.status": "Status", + "label.amount": "Amount", + "label.date": "Date", + "label.network": "Network", + "label.wallet": "Wallet", + "label.address": "Address", + "label.balance": "Balance", + + // ── Auth ──────────────────────────────────────────────────────────────────── + "auth.sign_in": "Sign In", + "auth.sign_out": "Sign Out", + "auth.connect_prompt": "Connect your Stellar wallet to get started.", + "auth.wallet_required": "A connected wallet is required.", + + // ── Dashboard ──────────────────────────────────────────────────────────────── + "dashboard.title": "Dashboard", + "dashboard.welcome": "Welcome back, {name}!", + "dashboard.stats.total_predictions": "Total Predictions", + "dashboard.stats.win_rate": "Win Rate", + "dashboard.stats.total_earned": "Total Earned", + "dashboard.stats.active_bets": "Active Bets", + "dashboard.no_activity": "No recent activity to show.", + + // ── Events ────────────────────────────────────────────────────────────────── + "events.title": "Events", + "events.create": "Create Event", + "events.empty": "No events found.", + "events.filter.all": "All", + "events.filter.open": "Open", + "events.filter.closed": "Closed", + "events.filter.resolved": "Resolved", + "events.status.open": "Open", + "events.status.closed": "Closed", + "events.status.resolving": "Resolving", + "events.status.resolved": "Resolved", + "events.deadline": "Closes {date}", + "events.participants": "{count} participants", + + // ── Predictions ────────────────────────────────────────────────────────────── + "predictions.title": "My Predictions", + "predictions.empty": "You haven't made any predictions yet.", + "predictions.place_bet": "Place Bet", + "predictions.outcome.yes": "Yes", + "predictions.outcome.no": "No", + "predictions.stake": "Stake", + "predictions.potential_payout": "Potential Payout", + "predictions.odds": "Odds", + "predictions.result.won": "Won", + "predictions.result.lost": "Lost", + "predictions.result.pending": "Pending", + + // ── Wallet ────────────────────────────────────────────────────────────────── + "wallet.connect_title": "Connect Wallet", + "wallet.connect_description": "Choose a Stellar wallet to connect.", + "wallet.connecting": "Connecting…", + "wallet.connected": "Connected", + "wallet.disconnected": "Disconnected", + "wallet.copy_address": "Copy Address", + "wallet.address_copied": "Address copied!", + "wallet.network_testnet": "Testnet", + "wallet.network_mainnet": "Mainnet", + "wallet.error.not_found": "Wallet not found. Please install the extension.", + "wallet.error.rejected": "Connection rejected.", + "wallet.error.unknown": "An unknown wallet error occurred.", + + // ── Settings ──────────────────────────────────────────────────────────────── + "settings.title": "Settings", + "settings.language.title": "Language", + "settings.language.description": + "Choose the language used throughout the Predictify interface.", + "settings.language.active": "Active", + "settings.language.saved_note": + "Your preference is saved locally and applied immediately.", + "settings.account.title": "Account", + "settings.notifications.title": "Notifications", + "settings.appearance.title": "Appearance", + + // ── Disputes ──────────────────────────────────────────────────────────────── + "disputes.title": "Disputes", + "disputes.empty": "No disputes to review.", + "disputes.status.open": "Open", + "disputes.status.under_review": "Under Review", + "disputes.status.resolved": "Resolved", + "disputes.raise": "Raise Dispute", + "disputes.evidence": "Evidence", + + // ── Finances ──────────────────────────────────────────────────────────────── + "finances.title": "Finances", + "finances.deposit": "Deposit", + "finances.withdraw": "Withdraw", + "finances.transaction_history": "Transaction History", + "finances.no_transactions": "No transactions yet.", + + // ── Profile ───────────────────────────────────────────────────────────────── + "profile.title": "Profile", + "profile.edit": "Edit Profile", + "profile.share": "Share Profile", + "profile.stats.predictions": "Predictions", + "profile.stats.accuracy": "Accuracy", + "profile.stats.earnings": "Earnings", + + // ── Errors / feedback ──────────────────────────────────────────────────────── + "error.generic": "Something went wrong. Please try again.", + "error.network": "Network error. Check your connection.", + "error.not_found": "Page not found.", + "error.unauthorized": "You are not authorized to view this page.", + "feedback.copied": "Copied!", + "feedback.saved": "Saved.", + "feedback.transaction_submitted": "Transaction submitted!", + "feedback.transaction_confirmed": "Transaction confirmed.", + "feedback.transaction_failed": "Transaction failed.", + + // ── Accessibility ──────────────────────────────────────────────────────────── + "a11y.menu_open": "Open menu", + "a11y.menu_close": "Close menu", + "a11y.theme_toggle": "Toggle theme", + "a11y.language_select": "Select UI language", + "a11y.close_dialog": "Close dialog", + "a11y.loading": "Loading content, please wait.", + "a11y.external_link": "Opens in a new tab", +}; + +export default en; diff --git a/app/i18n/messages/index.ts b/app/i18n/messages/index.ts new file mode 100644 index 00000000..2c8c7e2c --- /dev/null +++ b/app/i18n/messages/index.ts @@ -0,0 +1,39 @@ +/** + * Message catalog loader. + * + * Returns the flat key→string map for a given locale code. Falls back to the + * English base if the requested locale has no catalog yet. + * + * Adding a new locale: + * 1. Create `app/i18n/messages/.ts` exporting a `Messages` object. + * 2. Import it here and add an entry to `CATALOGS`. + * 3. Ensure the locale code is listed in `SUPPORTED_LANGUAGES` in + * `app/i18n/index.ts`. + */ + +import type { Messages } from "../types"; +import en from "./en"; + +/** Map of every compiled locale catalog, keyed by BCP-47 code. */ +const CATALOGS: Record = { + en, + // Add further locales as they become available: + // es: () => import('./es').then(m => m.default), +}; + +/** + * Returns the message catalog for `locale`, or the English fallback if no + * catalog is registered for that locale. + * + * This is a synchronous lookup because all catalogs are bundled at build time. + * If you later split catalogs into dynamic imports, update the return type to + * `Promise` and adjust callers accordingly. + */ +export function getMessages(locale: string): Messages { + return CATALOGS[locale] ?? CATALOGS["en"]; +} + +/** All locale codes that have a compiled message catalog. */ +export const AVAILABLE_LOCALES: ReadonlyArray = Object.keys(CATALOGS); + +export { en as defaultMessages }; diff --git a/app/i18n/t.ts b/app/i18n/t.ts new file mode 100644 index 00000000..ba1616e6 --- /dev/null +++ b/app/i18n/t.ts @@ -0,0 +1,115 @@ +/** + * i18n — Translation helpers + * + * `createTranslator` builds a `t()` function bound to a specific locale's + * message catalog. `useTranslation` is a React hook that returns a `t()` + * function whose locale tracks the user's live language preference. + * + * Features: + * - Named placeholder interpolation: `t("key", { name: "Alice" })` + * - Falls back to the message key if no translation is found (never throws). + * - Zero runtime dependencies — works in SSR and client components. + * - Fully typed: keys autocomplete from the English catalog. + */ + +"use client"; + +import { useMemo } from "react"; +import type { Messages, InterpolationValues } from "./types"; +import { getMessages } from "./messages/index"; +import { getStoredLanguage } from "./index"; +import { useLanguage } from "@/hooks/useLanguage"; + +// ── Core interpolation ──────────────────────────────────────────────────────── + +/** + * Replaces `{variable}` placeholders in a message string with the + * corresponding values from `vars`. + * + * Placeholders that have no matching key in `vars` are left as-is so the + * developer can spot missing variables quickly. + * + * @example + * interpolate("Welcome, {name}!", { name: "Alice" }) // → "Welcome, Alice!" + * interpolate("Hello {name}!", {}) // → "Hello {name}!" + */ +export function interpolate( + message: string, + vars?: InterpolationValues +): string { + if (!vars || Object.keys(vars).length === 0) return message; + return message.replace(/\{(\w+)\}/g, (placeholder, key) => { + const value = vars[key]; + return value !== undefined ? String(value) : placeholder; + }); +} + +// ── Translator factory ──────────────────────────────────────────────────────── + +/** + * Creates a `t()` function bound to the provided message catalog. + * + * @param messages Flat key→string map (from `getMessages(locale)`). + * @returns A translator function `t(key, vars?) → string`. + * + * @example + * const t = createTranslator(getMessages("en")); + * t("nav.dashboard") // → "Dashboard" + * t("dashboard.welcome", { name: "Bob" }) // → "Welcome back, Bob!" + * t("nonexistent.key") // → "nonexistent.key" (safe fallback) + */ +export function createTranslator(messages: Messages) { + return function t(key: string, vars?: InterpolationValues): string { + const raw = messages[key]; + if (raw === undefined) { + // Return the key itself so missing strings are immediately visible in UI. + if (process.env.NODE_ENV !== "production") { + console.warn(`[i18n] Missing translation key: "${key}"`); + } + return key; + } + return interpolate(raw, vars); + }; +} + +// ── Server-side helper ──────────────────────────────────────────────────────── + +/** + * Builds a `t()` function for use outside React (e.g. API routes, metadata). + * Reads the stored language code from localStorage when in a browser context; + * defaults to "en" on the server. + */ +export function getTranslator(locale?: string): ReturnType { + const resolvedLocale = locale ?? getStoredLanguage(); + return createTranslator(getMessages(resolvedLocale)); +} + +// ── React hook ──────────────────────────────────────────────────────────────── + +/** + * React hook that returns a `t()` function bound to the user's active locale. + * Re-renders automatically when the user changes their language preference. + * + * @example + * function MyComponent() { + * const { t } = useTranslation(); + * return

{t("nav.dashboard")}

; + * } + */ +export function useTranslation() { + const { languageCode, isReady } = useLanguage(); + + const t = useMemo( + () => createTranslator(getMessages(languageCode)), + [languageCode] + ); + + return { + /** Translate a message key, optionally interpolating `{variable}` placeholders. */ + t, + /** BCP-47 code of the currently active locale. */ + locale: languageCode, + /** True once the client-side locale has been hydrated from localStorage. */ + isReady, + } as const; +} diff --git a/app/i18n/types.ts b/app/i18n/types.ts new file mode 100644 index 00000000..d67b4d93 --- /dev/null +++ b/app/i18n/types.ts @@ -0,0 +1,38 @@ +/** + * i18n — Type definitions for the message catalog. + * + * `Messages` is the canonical shape of a translation bundle. Each key is a + * dot-separated namespace path (e.g. `"nav.dashboard"`) and each value is a + * string that may contain `{variable}` placeholders. + * + * Keeping this as a flat record (rather than a nested tree) makes it trivial + * to iterate over all keys, detect missing translations, and keep per-locale + * files diff-friendly. + */ + +/** + * A flat record of translation keys to message strings. + * Message strings may contain named interpolation placeholders: `{name}`. + * + * @example + * const msg: Messages = { + * "greeting.welcome": "Welcome, {name}!", + * "nav.dashboard": "Dashboard", + * }; + */ +export type Messages = Record; + +/** + * A union of every key present in the English base catalog. + * Import from `@/app/i18n` – it is re-exported there after being derived + * automatically from the `en` messages object. + */ +export type MessageKey = string & { readonly __brand: "MessageKey" }; + +/** + * Variables map passed to the `t()` function for placeholder interpolation. + * + * @example + * t("greeting.welcome", { name: "Alice" }) // → "Welcome, Alice!" + */ +export type InterpolationValues = Record; diff --git a/app/settings/language/page.tsx b/app/settings/language/page.tsx index c1d16604..ca05578f 100644 --- a/app/settings/language/page.tsx +++ b/app/settings/language/page.tsx @@ -16,22 +16,29 @@ */ import { useLanguage } from "@/hooks/useLanguage"; +import { useTranslation } from "@/app/i18n"; export default function LanguageSettingsPage() { const { languageCode, setLanguage, isReady, languages } = useLanguage(); + const { t } = useTranslation(); return (
{/* Page heading */} -

Language

+

+ {t("settings.language.title")} +

- Choose the language used throughout the Predictify interface. + {t("settings.language.description")}

{/* Language selector */} -
- UI language preference +
+ {t("a11y.language_select")}
    {languages.map((lang) => { const isSelected = lang.code === languageCode; @@ -74,7 +81,9 @@ export default function LanguageSettingsPage() { )} {isSelected && ( - Active + + {t("settings.language.active")} + )} @@ -85,8 +94,7 @@ export default function LanguageSettingsPage() { {/* Helper note */}

    - Your preference is saved locally and applied immediately. A full - translation rollout will follow in a future release. + {t("settings.language.saved_note")}

diff --git a/components/providers.tsx b/components/providers.tsx index 2148e6ad..d4f059d3 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -9,6 +9,7 @@ import { ReactNode } from "react"; import { useHideBalancesShortcut } from "@/hooks/useHideBalancesShortcut"; import { ClaimShareProvider } from "@/context/ClaimShareContext"; import { RouteDocumentTitle } from "@/app/hooks/useDocumentTitle"; +import { LangAttribute } from "@/app/i18n/LangAttribute"; interface ProvidersProps { children: ReactNode; @@ -24,6 +25,8 @@ export function Providers({ children }: ProvidersProps) { return ( + {/* Keeps in sync with the user's language preference. */} +