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));
|
||||
}
|
||||
@@ -3,8 +3,23 @@ import {
|
||||
createTheme,
|
||||
MantineProvider as MantineProviderCore,
|
||||
} from "@mantine/core";
|
||||
import { DatesProvider } from "@mantine/dates";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import type { AppLocale } from "@/lib/i18n";
|
||||
import ColorSchemeProvider from "./color-scheme-provider";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import "dayjs/locale/es-mx";
|
||||
import "dayjs/locale/de";
|
||||
import "dayjs/locale/ja";
|
||||
|
||||
// Mantine calendars pull month/weekday names from dayjs, which needs its own
|
||||
// locale code (es → es-mx) and the locale files imported above.
|
||||
const DAYJS_LOCALE: Record<AppLocale, string> = {
|
||||
en: "en",
|
||||
es: "es-mx",
|
||||
de: "de",
|
||||
ja: "ja",
|
||||
};
|
||||
|
||||
const commonInputStyles = {
|
||||
label: {
|
||||
@@ -70,6 +85,7 @@ const theme = createTheme({
|
||||
|
||||
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { metadata } = useAuth();
|
||||
const { i18n } = useLingui();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,7 +108,13 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
<style href="flxn-press-feedback" precedence="medium">
|
||||
{pressFeedbackCss}
|
||||
</style>
|
||||
<ColorSchemeProvider>{children}</ColorSchemeProvider>
|
||||
<DatesProvider
|
||||
settings={{
|
||||
locale: DAYJS_LOCALE[i18n.locale as AppLocale] ?? "en",
|
||||
}}
|
||||
>
|
||||
<ColorSchemeProvider>{children}</ColorSchemeProvider>
|
||||
</DatesProvider>
|
||||
</MantineProviderCore>
|
||||
);
|
||||
};
|
||||
|
||||
+45
-2
@@ -1,7 +1,10 @@
|
||||
import webpush from "web-push";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import type { I18n, MessageDescriptor } from "@lingui/core";
|
||||
import { vapidConfig, isPushConfigured } from "@/lib/config";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { createI18n, resolveLocale } from "@/lib/i18n";
|
||||
import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
|
||||
|
||||
export interface PushPayload {
|
||||
@@ -15,6 +18,35 @@ export interface PushPayload {
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/** A literal string (e.g. the brand name, never translated) or a localizable message descriptor. */
|
||||
export type PushText = string | MessageDescriptor;
|
||||
|
||||
export interface LocalizedPushPayload extends Omit<PushPayload, "title" | "body"> {
|
||||
title: PushText;
|
||||
body: PushText;
|
||||
}
|
||||
|
||||
function resolvePushText(i18n: I18n, text: PushText): string {
|
||||
return typeof text === "string" ? text : i18n._(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* sendPushToPlayer only has the recipient's playerId, not a request context,
|
||||
* so it resolves their locale from SuperTokens user metadata directly.
|
||||
* Falls back to the default locale for players with no linked auth account
|
||||
* or no stored locale preference.
|
||||
*/
|
||||
async function resolveRecipientLocale(playerId: string) {
|
||||
try {
|
||||
const player = await pbAdmin.getPlayer(playerId);
|
||||
if (!player.auth_id) return resolveLocale(undefined);
|
||||
const { metadata } = await UserMetadata.getUserMetadata(player.auth_id);
|
||||
return resolveLocale(metadata?.locale);
|
||||
} catch {
|
||||
return resolveLocale(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let vapidInitialized = false;
|
||||
|
||||
function ensureVapid(): boolean {
|
||||
@@ -61,13 +93,24 @@ export async function sendPush(
|
||||
* - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
|
||||
* NONE of these are wired yet — this foundation pass only exposes the seam
|
||||
* plus a manual "test notification" path.
|
||||
*
|
||||
* title/body accept either a literal string (e.g. the brand name) or a
|
||||
* msg`` descriptor, resolved against the recipient's own locale.
|
||||
*/
|
||||
export async function sendPushToPlayer(
|
||||
playerId: string,
|
||||
payload: PushPayload
|
||||
payload: LocalizedPushPayload
|
||||
): Promise<{ sent: number; failed: number; pruned: number }> {
|
||||
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
|
||||
|
||||
const locale = await resolveRecipientLocale(playerId);
|
||||
const i18n = createI18n(locale);
|
||||
const resolvedPayload: PushPayload = {
|
||||
...payload,
|
||||
title: resolvePushText(i18n, payload.title),
|
||||
body: resolvePushText(i18n, payload.body),
|
||||
};
|
||||
|
||||
const subscriptions: PushSubscriptionRecord[] =
|
||||
await pbAdmin.listPushSubscriptionsByPlayer(playerId);
|
||||
|
||||
@@ -83,7 +126,7 @@ export async function sendPushToPlayer(
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
payload
|
||||
resolvedPayload
|
||||
);
|
||||
sent++;
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { useSession } from "@tanstack/react-start/server";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import SuperTokens from "supertokens-node";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
||||
import { resolveLocale } from "@/lib/i18n";
|
||||
|
||||
// The user isn't authenticated yet when the login code is sent, so the SMS
|
||||
// locale comes from the stored preference of whichever user owns the phone
|
||||
// number; new users fall back to English.
|
||||
const getLocaleForPhone = async (phoneNumber: string): Promise<string> => {
|
||||
try {
|
||||
const users = await SuperTokens.listUsersByAccountInfo("public", {
|
||||
phoneNumber,
|
||||
});
|
||||
const userId = users[0]?.id;
|
||||
if (!userId) return "en";
|
||||
|
||||
const { metadata } = await UserMetadata.getUserMetadata(userId);
|
||||
return resolveLocale(metadata?.locale);
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
};
|
||||
|
||||
const init = () =>
|
||||
Passwordless.init({
|
||||
@@ -15,7 +36,8 @@ const init = () =>
|
||||
throw new Error("No user input code provided to sendSms");
|
||||
}
|
||||
|
||||
const sid = await sendVerifyCode(phoneNumber, userInputCode);
|
||||
const locale = await getLocaleForPhone(phoneNumber);
|
||||
const sid = await sendVerifyCode(phoneNumber, userInputCode, locale);
|
||||
|
||||
const session = await useSession({
|
||||
password: preAuthSessionId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { ServerResult } from "../types";
|
||||
import toast from '@/lib/sonner'
|
||||
import { logger } from '@/lib/supertokens'
|
||||
import { localizeServerError } from '@/lib/i18n/error-messages'
|
||||
|
||||
|
||||
let sessionRefreshRedirect: Promise<void> | null = null;
|
||||
@@ -14,14 +16,15 @@ export function useServerMutation<TData, TVariables = unknown>(
|
||||
showSuccessToast?: boolean;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
mutationFn,
|
||||
successMessage,
|
||||
showErrorToast = true,
|
||||
const { i18n } = useLingui();
|
||||
const {
|
||||
mutationFn,
|
||||
successMessage,
|
||||
showErrorToast = true,
|
||||
showSuccessToast = true,
|
||||
onSuccess,
|
||||
onError,
|
||||
...mutationOptions
|
||||
...mutationOptions
|
||||
} = options;
|
||||
|
||||
return useMutation({
|
||||
@@ -29,12 +32,13 @@ export function useServerMutation<TData, TVariables = unknown>(
|
||||
mutationFn: async (variables: TVariables) => {
|
||||
try {
|
||||
const result = await mutationFn(variables);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
const message = localizeServerError(i18n, result.error);
|
||||
if (showErrorToast) {
|
||||
toast.error(result.error.userMessage);
|
||||
toast.error(message);
|
||||
}
|
||||
throw new Error(result.error.userMessage);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { ServerResult } from "../types";
|
||||
import toast from '@/lib/sonner'
|
||||
import { localizeServerError } from '@/lib/i18n/error-messages'
|
||||
|
||||
export function useServerQuery<TData>(
|
||||
options: {
|
||||
@@ -12,20 +14,22 @@ export function useServerQuery<TData>(
|
||||
}
|
||||
) {
|
||||
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return useQuery({
|
||||
...queryOptions,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const result = await queryFn();
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
const message = localizeServerError(i18n, result.error);
|
||||
if (showErrorToast) {
|
||||
toast.error(result.error.userMessage);
|
||||
toast.error(message);
|
||||
}
|
||||
throw new Error(result.error.userMessage);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
|
||||
return result.data;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { ServerResult } from "../types";
|
||||
import toast from '@/lib/sonner'
|
||||
import { localizeServerError } from '@/lib/i18n/error-messages'
|
||||
|
||||
export function useServerSuspenseQuery<TData>(
|
||||
options: {
|
||||
@@ -12,19 +14,21 @@ export function useServerSuspenseQuery<TData>(
|
||||
}
|
||||
) {
|
||||
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
|
||||
const { i18n } = useLingui();
|
||||
const queryResult = useSuspenseQuery({
|
||||
...queryOptions,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const result = await queryFn();
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
const message = localizeServerError(i18n, result.error);
|
||||
if (showErrorToast) {
|
||||
toast.error(result.error.userMessage);
|
||||
toast.error(message);
|
||||
}
|
||||
throw new Error(result.error.userMessage);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
|
||||
return result.data;
|
||||
}
|
||||
});
|
||||
|
||||
+14
-1
@@ -23,17 +23,30 @@ function getTwilioClient() {
|
||||
}
|
||||
|
||||
|
||||
export async function sendVerifyCode(phoneNumber: string, code: string) {
|
||||
// Twilio Verify only accepts its documented language codes; anything else
|
||||
// (e.g. sw) must be omitted so the send doesn't fail — Twilio falls back to
|
||||
// the service default (English).
|
||||
const TWILIO_VERIFY_LOCALES = new Set(["en", "es", "de", "ja"]);
|
||||
|
||||
export async function sendVerifyCode(
|
||||
phoneNumber: string,
|
||||
code: string,
|
||||
locale?: string
|
||||
) {
|
||||
const { serviceSid } = getEnvVars();
|
||||
|
||||
const twilioClient = getTwilioClient();
|
||||
|
||||
const verifyLocale =
|
||||
locale && TWILIO_VERIFY_LOCALES.has(locale) ? locale : undefined;
|
||||
|
||||
const verification = await twilioClient!.verify.v2
|
||||
.services(serviceSid)
|
||||
.verifications.create({
|
||||
channel: "sms",
|
||||
customCode: code,
|
||||
to: phoneNumber,
|
||||
...(verifyLocale ? { locale: verifyLocale } : {}),
|
||||
});
|
||||
|
||||
if (verification.status !== "pending") {
|
||||
|
||||
Reference in New Issue
Block a user