130 lines
3.3 KiB
TypeScript
130 lines
3.3 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import { useSettingsStore, type Lang } from "@/lib/settings";
|
|
import { ar } from "@/lib/translations/ar";
|
|
import { en } from "@/lib/translations/en";
|
|
import { fr } from "@/lib/translations/fr";
|
|
|
|
export type Vars = Record<string, string | number>;
|
|
|
|
/**
|
|
* Lightweight i18n. No external dependency: three languages, dot-path keys,
|
|
* `{var}` interpolation, and a `one`/`other`/`zero` plural convention that
|
|
* covers the few counts this app shows (minutes, seats).
|
|
*/
|
|
const DICTS: Record<Lang, Record<string, unknown>> = { en, ar, fr };
|
|
|
|
const interpolate = (text: string, vars?: Vars): string =>
|
|
vars
|
|
? text.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? ""))
|
|
: text;
|
|
|
|
const readPath = (dict: Record<string, unknown>, path: string): unknown =>
|
|
path.split(".").reduce<unknown>(
|
|
(acc, segment) =>
|
|
acc && typeof acc === "object"
|
|
? (acc as Record<string, unknown>)[segment]
|
|
: undefined,
|
|
dict,
|
|
);
|
|
|
|
const translate = (
|
|
lang: Lang,
|
|
key: string,
|
|
vars?: Vars,
|
|
count?: number,
|
|
): string => {
|
|
const dict = DICTS[lang] ?? en;
|
|
|
|
if (count !== undefined) {
|
|
const pluralSuffix =
|
|
count === 0 ? "zero" : count === 1 ? "one" : "other";
|
|
const pluralValue = readPath(dict, `${key}.${pluralSuffix}`);
|
|
|
|
if (typeof pluralValue === "string") {
|
|
return interpolate(pluralValue, { n: count, ...vars });
|
|
}
|
|
}
|
|
|
|
const value = readPath(dict, key);
|
|
|
|
if (typeof value === "string") return interpolate(value, vars);
|
|
|
|
// Fall back to English, then to the key itself, so a missing translation
|
|
// never renders an empty string.
|
|
if (lang !== "en") {
|
|
const fallback = readPath(en, key);
|
|
if (typeof fallback === "string") return interpolate(fallback, vars);
|
|
}
|
|
|
|
return key;
|
|
};
|
|
|
|
/**
|
|
* Module-level translator for non-React helpers (`lib/utils.ts`,
|
|
* `lib/pricing.ts`) that can't call `useT`. The I18nProvider sets this on
|
|
* mount and whenever the language changes; until then it defaults to English.
|
|
*/
|
|
let activeLang: Lang = "en";
|
|
|
|
export const setTranslator = (lang: Lang) => {
|
|
activeLang = lang;
|
|
};
|
|
|
|
export const tr = (key: string, vars?: Vars, count?: number): string =>
|
|
translate(activeLang, key, vars, count);
|
|
|
|
type I18nContextValue = {
|
|
lang: Lang;
|
|
isRTL: boolean;
|
|
t: (key: string, vars?: Vars, count?: number) => string;
|
|
};
|
|
|
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
|
|
|
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
|
const lang = useSettingsStore((state) => state.lang);
|
|
|
|
useEffect(() => {
|
|
setTranslator(lang);
|
|
}, [lang]);
|
|
|
|
const value = useMemo<I18nContextValue>(
|
|
() => ({
|
|
lang,
|
|
isRTL: lang === "ar",
|
|
t: (key, vars, count) => translate(lang, key, vars, count),
|
|
}),
|
|
[lang],
|
|
);
|
|
|
|
return (
|
|
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useT = (): I18nContextValue["t"] => {
|
|
const context = useContext(I18nContext);
|
|
|
|
if (!context) {
|
|
throw new Error("useT must be used within I18nProvider.");
|
|
}
|
|
|
|
return context.t;
|
|
};
|
|
|
|
export const useI18n = (): I18nContextValue => {
|
|
const context = useContext(I18nContext);
|
|
|
|
if (!context) {
|
|
throw new Error("useI18n must be used within I18nProvider.");
|
|
}
|
|
|
|
return context;
|
|
}; |