i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped

This commit is contained in:
2026-08-08 15:29:40 -07:00
parent 22c282d8fa
commit 0a7b5fa00f
178 changed files with 13099 additions and 1285 deletions
+23
View File
@@ -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;
}
+73
View File
@@ -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;
}
+26
View File
@@ -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,
};
}
+61
View File
@@ -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>;
};
+12
View File
@@ -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));
}
+23 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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") {