Files
office_translator/frontend/src/lib/i18n.tsx
sepehr 50047ea8a2
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA
combobox + listbox pattern for language selector, role=switch on glossary
toggle, role=status live region on notifications, aria-labels on password
toggles, visible-on-focus close buttons, RTL logical positioning (start-*).

Trust: third-party Memento promo removed from translate page, sidebar and
all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity
bars) replaced with real measurements incl. API estimated remaining time;
silent download failure now surfaces an error notification.

Honesty: fake 100-byte file injections removed (format chips are now
informational); cancel-that-doesn't renamed 'Back to start' with hint;
Enterprise contact placeholder replaced with contact@wordly.art.

i18n: t() no longer returns raw keys (empty string + defaultValue support,
~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/
teams namespaces, glossaries context tab, translate monitor, settings,
services, pricing, landing, fileUploader; split-key italic titles replace
lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing.

Flow: active job persisted across refresh with polling resume (24h TTL);
client-side recent-jobs history with review links; review page linked from
complete state; settings/services added to dashboard nav; Business/
Enterprise regain glossary access (tier gate unified).

Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30
opacity to /45-/55, uppercase tracking reduced, trust footer legible,
country flags removed from language switcher, localized dates.

Cleanup: 5 orphaned translate components, dead site header/footer,
fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles,
two-step confirm for cache clear, dead landing footer links.

Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD
(no regression, -3 warnings), detector 4 -> 3 findings.
2026-08-30 21:44:53 +02:00

198 lines
4.8 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useMemo,
type ReactNode,
} from "react";
import enMessages from "./i18n/messages/en";
export type Locale =
| "en"
| "fr"
| "es"
| "de"
| "pt"
| "it"
| "nl"
| "ru"
| "ja"
| "ko"
| "zh"
| "ar"
| "fa";
const VALID_LOCALES: ReadonlyArray<Locale> = [
"en",
"fr",
"es",
"de",
"pt",
"it",
"nl",
"ru",
"ja",
"ko",
"zh",
"ar",
"fa",
];
const DEFAULT_LOCALE: Locale = "en";
const RTL_LOCALES: ReadonlyArray<Locale> = ["ar", "fa"];
type TranslationParams = Record<string, string | number>;
interface I18nContextValue {
locale: Locale;
dir: "ltr" | "rtl";
isRTL: boolean;
setLocale: (locale: Locale) => void;
t: (key: string, params?: TranslationParams) => string;
isLoading: boolean;
}
const I18nContext = createContext<I18nContextValue | null>(null);
const messageCache: Partial<Record<Locale, Record<string, string>>> = {
en: enMessages,
};
function interpolate(template: string, params?: TranslationParams): string {
if (!params) return template;
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
template,
);
}
function detectInitialLocale(): Locale {
if (typeof window === "undefined") return DEFAULT_LOCALE;
const saved = localStorage.getItem("locale");
if (saved && (VALID_LOCALES as readonly string[]).includes(saved)) {
return saved as Locale;
}
const primary = navigator.language.split("-")[0];
if ((VALID_LOCALES as readonly string[]).includes(primary)) {
return primary as Locale;
}
const allLangs = navigator.languages || [];
for (const lang of allLangs) {
const code = lang.split("-")[0];
if ((VALID_LOCALES as readonly string[]).includes(code)) {
return code as Locale;
}
}
return DEFAULT_LOCALE;
}
async function loadLocaleMessages(locale: Locale): Promise<Record<string, string>> {
if (messageCache[locale]) return messageCache[locale];
try {
const mod = await import(`./i18n/messages/${locale}`);
messageCache[locale] = mod.default as Record<string, string>;
return messageCache[locale];
} catch (err) {
console.warn(`Failed to load locale messages for ${locale}`, err);
messageCache[locale] = enMessages;
return enMessages;
}
}
export function formatDate(
date: Date,
locale: Locale,
options?: Intl.DateTimeFormatOptions,
): string {
const calendar =
locale === "fa" ? "fa-IR-u-ca-persian" : locale === "ar" ? "ar-SA" : locale;
const defaults: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(calendar, { ...defaults, ...options });
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectInitialLocale);
const [messages, setMessages] = useState<Record<string, string>>(enMessages);
const [isLoading, setIsLoading] = useState(false);
const isRTL = (RTL_LOCALES as readonly string[]).includes(locale);
const dir = isRTL ? ("rtl" as const) : ("ltr" as const);
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = locale;
}, [locale, dir]);
useEffect(() => {
let cancelled = false;
if (locale === "en") {
setMessages(enMessages);
return;
}
setIsLoading(true);
loadLocaleMessages(locale).then((loaded) => {
if (!cancelled) {
setMessages(loaded);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [locale]);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem("locale", newLocale);
}, []);
const t = useCallback(
(key: string, params?: TranslationParams): string => {
const msg = messages[key] ?? enMessages[key];
if (msg === undefined) {
// Missing everywhere: honour an explicit defaultValue, else return ""
// so `t(key) || fallback` call sites work and raw keys never reach the UI.
return params && "defaultValue" in params
? String(params.defaultValue)
: "";
}
const rest = { ...params };
delete rest.defaultValue;
return interpolate(msg, rest);
},
[messages],
);
const value = useMemo(
() => ({ locale, dir, isRTL, setLocale, t, isLoading }),
[locale, dir, isRTL, setLocale, t, isLoading],
);
return (
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
);
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n must be used within an I18nProvider");
}
return ctx;
}
export function useTranslation() {
const { t } = useI18n();
return { t };
}