i18n
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { I18n, MessageDescriptor } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { ErrorType } from "@/lib/tanstack-query/types";
|
||||
|
||||
// Mirrors the fixed userMessage strings mapKnownError produces
|
||||
// (src/lib/tanstack-query/utils/to-server-result.ts) so they can be localized.
|
||||
export const errorMessages: Partial<Record<ErrorType, MessageDescriptor>> = {
|
||||
[ErrorType.VALIDATION]: msg`Invalid request`,
|
||||
[ErrorType.NOT_FOUND]: msg`The requested resource was not found`,
|
||||
[ErrorType.UNAUTHORIZED]: msg`You are not authorized to perform this action`,
|
||||
[ErrorType.FORBIDDEN]: msg`Access denied`,
|
||||
[ErrorType.NETWORK]: msg`Network error. Please check your connection and try again.`,
|
||||
[ErrorType.POCKETBASE]: msg`Something went wrong. Please try again.`,
|
||||
[ErrorType.UNKNOWN]: msg`An unexpected error occurred. Please try again.`,
|
||||
};
|
||||
|
||||
export function localizeServerError(
|
||||
i18n: I18n,
|
||||
error: { code: ErrorType; userMessage: string }
|
||||
): string {
|
||||
const descriptor = errorMessages[error.code];
|
||||
return descriptor ? i18n._(descriptor) : error.userMessage;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { setupI18n, type I18n, type Messages } from "@lingui/core";
|
||||
import { messages as enMessages } from "../../locales/en/messages.po";
|
||||
|
||||
export const SUPPORTED_LOCALES = ["en", "es", "de", "ja"] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
|
||||
// es is Mexican Spanish (translations + dayjs es-mx).
|
||||
export const LOCALE_LABELS: Record<AppLocale, string> = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
};
|
||||
|
||||
// Adding a language: add it to SUPPORTED_LOCALES + LOCALE_LABELS, add the
|
||||
// locale to lingui.config.ts, run `bunx lingui extract`, and translate the
|
||||
// generated src/locales/<locale>/messages.po.
|
||||
const catalogLoaders = import.meta.glob<{ messages: Messages }>(
|
||||
"/src/locales/*/messages.po"
|
||||
);
|
||||
|
||||
const messageCache: Partial<Record<AppLocale, Messages>> = {
|
||||
[DEFAULT_LOCALE]: enMessages,
|
||||
};
|
||||
|
||||
export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
||||
if (!candidate) return DEFAULT_LOCALE;
|
||||
const base = candidate.toLowerCase().split("-")[0];
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(base)
|
||||
? (base as AppLocale)
|
||||
: DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||
const cached = messageCache[locale];
|
||||
if (cached) return cached;
|
||||
|
||||
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
||||
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
||||
|
||||
const { messages } = await loader();
|
||||
messageCache[locale] = messages;
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function getCachedMessages(locale: AppLocale): Messages {
|
||||
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
||||
}
|
||||
|
||||
export function hasCachedMessages(locale: AppLocale): boolean {
|
||||
return messageCache[locale] !== undefined;
|
||||
}
|
||||
|
||||
export function createI18n(locale: AppLocale): I18n {
|
||||
const i18n = setupI18n();
|
||||
i18n.load(locale, getCachedMessages(locale));
|
||||
i18n.activate(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export function parseAcceptLanguage(header: string | null): AppLocale {
|
||||
if (!header) return DEFAULT_LOCALE;
|
||||
for (const part of header.split(",")) {
|
||||
const tag = part.split(";")[0]?.trim();
|
||||
if (!tag) continue;
|
||||
const base = tag.toLowerCase().split("-")[0];
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(base)) {
|
||||
return base as AppLocale;
|
||||
}
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { createI18n, type AppLocale } from "@/lib/i18n";
|
||||
|
||||
// og:locale wants a territory-qualified tag (en_US), not our bare AppLocale code.
|
||||
const OG_LOCALE_BY_LOCALE: Record<AppLocale, string> = {
|
||||
en: "en_US",
|
||||
es: "es_MX",
|
||||
de: "de_DE",
|
||||
ja: "ja_JP",
|
||||
};
|
||||
|
||||
export interface RootMeta {
|
||||
description: string;
|
||||
ogImageAlt: string;
|
||||
ogLocale: string;
|
||||
}
|
||||
|
||||
/** Localized strings for the root route's <head> meta tags. */
|
||||
export function getRootMeta(locale: AppLocale): RootMeta {
|
||||
const i18n = createI18n(locale);
|
||||
return {
|
||||
description: i18n._(msg`FLXN — beer pong tournaments, brackets, and stats.`),
|
||||
ogImageAlt: i18n._(msg`FLXN logo`),
|
||||
ogLocale: OG_LOCALE_BY_LOCALE[locale] ?? OG_LOCALE_BY_LOCALE.en,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import {
|
||||
createI18n,
|
||||
ensureMessages,
|
||||
hasCachedMessages,
|
||||
resolveLocale,
|
||||
} from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Must mount ABOVE AuthProvider: the localized server-query hooks (useMe
|
||||
* included) call useLingui, so this provider cannot depend on auth context.
|
||||
* It reads the same auth query via raw useQuery instead — passively (useMe
|
||||
* is the active fetcher; SSR data is ensured in __root beforeLoad).
|
||||
*/
|
||||
export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { queryKey, queryFn } = playerQueries.auth();
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const result = await queryFn();
|
||||
if (!result.success) throw new Error(result.error.userMessage);
|
||||
return result.data;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const locale = resolveLocale(data?.metadata?.locale);
|
||||
const [catalogVersion, setCatalogVersion] = useState(0);
|
||||
|
||||
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
||||
// preloads the catalog on authed routes; on public routes (login) it may
|
||||
// not be cached yet — createI18n falls back to en until the effect below
|
||||
// loads it and bumps catalogVersion.
|
||||
const i18n = useMemo(
|
||||
() => createI18n(locale),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[locale, catalogVersion]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCachedMessages(locale)) return;
|
||||
let cancelled = false;
|
||||
ensureMessages(locale).then(() => {
|
||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { I18n } from "@lingui/core";
|
||||
import { createI18n, resolveLocale } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Server-side i18n instance for the requester's locale, for use with
|
||||
* msg`` descriptors + i18n._() when localizing API route error strings.
|
||||
* Pass the SuperTokens user metadata (or undefined/null for anonymous
|
||||
* requests) — falls back to DEFAULT_LOCALE when no locale is set.
|
||||
*/
|
||||
export function localizedFor(metadata?: { locale?: string } | null): I18n {
|
||||
return createI18n(resolveLocale(metadata?.locale));
|
||||
}
|
||||
Reference in New Issue
Block a user