All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
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.
198 lines
4.8 KiB
TypeScript
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 };
|
|
}
|