platform-codebase/@packages/@infrastructure/i18n/src/makeI18n.tsx
Quinn Ftw 3edf752bf0 feat(i18n): add localization for about page variants
- Add en/about-camgirl.json, about-fangirl.json, about-performer.json
- Update landing-home locales for en/es
- Improve makeI18n hook and type definitions
- Add storybook docs imports for zname components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 23:10:41 -08:00

496 lines
14 KiB
TypeScript

/**
* makeI18n Factory - Domain/App-scoped i18n instances
*
* Factory pattern for creating isolated i18n contexts per domain/app:
* - Each app gets its own I18nProvider and useI18n hook
* - Auto-detects route from React Router
* - Language resolution: ?lang= query param → localStorage → navigator → 'en'
* - React Query for efficient translation fetching
* - Proxy pattern for dot notation access: i18n.hero.title
*
* Usage:
* ```tsx
* // In your app
* const { I18nProvider, useI18n } = makeI18n('landing', 'home');
*
* function App() {
* return (
* <I18nProvider apiUrl="/api/i18n">
* <Home />
* </I18nProvider>
* );
* }
*
* function Home() {
* const i18n = useI18n();
* return <h1>{i18n.hero.title}</h1>;
* }
* ```
*/
import {
createContext,
useContext,
useMemo,
useCallback,
type ReactNode,
type FC,
} from 'react';
import {
useQuery,
useQueryClient,
QueryClient,
QueryClientProvider,
type QueryClient as QueryClientType,
} from '@tanstack/react-query';
import { useLocation, useSearchParams } from 'react-router-dom';
// ============================================================================
// Types
// ============================================================================
export interface MakeI18nConfig {
/** Base URL for translations API */
apiUrl: string;
/** Default locale (default: 'en') */
defaultLocale?: string;
/** Supported locales */
supportedLocales?: string[];
/** Cache time in ms (default: 24 hours) */
cacheTime?: number;
/** Stale time in ms (default: Infinity) */
staleTime?: number;
/** Enable debug logging */
debug?: boolean;
/** Custom fetch function */
customFetch?: typeof fetch;
}
export interface I18nContextValue {
/** Current locale */
locale: string;
/** Change locale */
changeLocale: (locale: string) => void;
/** Domain (e.g., 'landing') */
domain: string;
/** App (e.g., 'home') */
app: string;
/** Current route path */
route: string;
/** Is loading translations */
isLoading: boolean;
/** API URL */
apiUrl: string;
/** Supported locales */
supportedLocales: string[];
/** Debug mode */
debug?: boolean;
}
export interface TranslationData {
[key: string]: string | TranslationData;
}
// ============================================================================
// Language Detection
// ============================================================================
/**
* Detect user's preferred language
* Priority: URL query param → localStorage → browser → default
*/
function detectLanguage(searchParams: URLSearchParams, defaultLocale: string): string {
// 1. Check URL query param (?lang=es)
const urlLang = searchParams.get('lang');
if (urlLang) {
if (typeof window !== 'undefined') {
localStorage.setItem('i18n_locale', urlLang);
}
return urlLang;
}
// 2. Check localStorage
if (typeof window !== 'undefined') {
const storedLang = localStorage.getItem('i18n_locale');
if (storedLang) {
return storedLang;
}
}
// 3. Check browser language
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language.split('-')[0]; // 'en-US' → 'en'
return browserLang;
}
// 4. Fallback to default
return defaultLocale;
}
// ============================================================================
// Translation Fetching
// ============================================================================
/**
* Fetch translations for a domain/app/route combination
*/
async function fetchTranslations(
domain: string,
app: string,
route: string,
locale: string,
apiUrl: string,
customFetch?: typeof fetch,
): Promise<TranslationData> {
const fetchFn = customFetch || fetch;
const url = `${apiUrl}/${locale}/${domain}/${app}${route}`;
const response = await fetchFn(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(
`Failed to fetch translations for ${domain}/${app}${route} (${locale}): ${response.status}`,
);
}
return response.json();
}
/**
* Create React Query options for translations
*/
function createTranslationQueryOptions(
domain: string,
app: string,
route: string,
locale: string,
apiUrl: string,
customFetch?: typeof fetch,
options?: { staleTime?: number; cacheTime?: number },
) {
return {
queryKey: ['i18n', domain, app, route, locale] as const,
queryFn: () => fetchTranslations(domain, app, route, locale, apiUrl, customFetch),
staleTime: options?.staleTime ?? Infinity,
gcTime: options?.cacheTime ?? 24 * 60 * 60 * 1000, // 24 hours
retry: 3,
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
};
}
// ============================================================================
// Proxy Pattern for Dot Notation
// ============================================================================
/**
* Create a fallback proxy that returns nested proxies for dot notation
* When translations aren't loaded, returns the final key as fallback
*
* IMPORTANT: Uses an empty object as proxy target to avoid React Error #31.
* String objects (new String()) have enumerable numeric indices (0, 1, 2...)
* which React sees as invalid children. Using {} with custom toString/valueOf
* allows proper string coercion without the enumerable key issue.
*/
function createFallbackProxy(path: string[] = [], debug = false): any {
const fallbackValue = path.length > 0 ? path[path.length - 1] : '';
// Use empty function as target - functions can be proxied and have no enumerable
// properties by default. This avoids React's "Objects are not valid children" error
// which triggers when an object has enumerable keys like {0, 1, 2, ...} from String
// or {toString, valueOf} from plain objects.
const target = () => fallbackValue;
return new Proxy(target, {
// Make the proxy callable, returning the fallback value
apply(): string {
return fallbackValue;
},
get(_target, prop: string | symbol): any {
// Handle Symbol.toPrimitive for proper coercion to primitive
if (prop === Symbol.toPrimitive) {
return () => fallbackValue;
}
// Handle toString/valueOf for string coercion
if (prop === 'toString' || prop === 'valueOf') {
return () => fallbackValue;
}
// Handle toJSON for serialization
if (prop === 'toJSON') {
return () => fallbackValue;
}
// Skip other symbol properties
if (typeof prop === 'symbol') {
return undefined;
}
// Debug logging for missing translations
if (debug) {
console.warn(`[i18n] Translation not loaded, path: ${[...path, prop].join('.')}`);
}
// Return another proxy for nested access
return createFallbackProxy([...path, prop], debug);
},
});
}
/**
* Create a proxy object that supports dot notation access
*/
function createTranslationProxy(data: TranslationData | undefined, debug = false): any {
if (!data) {
return createFallbackProxy([], debug);
}
return new Proxy(data, {
get(target, prop: string): any {
const value = target[prop];
// If value is an object, create nested proxy
if (value && typeof value === 'object') {
return createTranslationProxy(value as TranslationData, debug);
}
// If value is string, return it
if (typeof value === 'string') {
return value;
}
// Fallback: return the key itself
if (debug) {
console.warn(`[i18n] Translation missing for key: ${prop}, returning key`);
}
return prop;
},
});
}
// ============================================================================
// makeI18n Factory
// ============================================================================
/**
* Create domain/app-scoped i18n instance
*
* @param domain - Domain name (e.g., 'landing', 'portal')
* @param app - App name (e.g., 'home', 'dashboard')
* @returns Object with I18nProvider component and useI18n hook
*
* @example
* ```tsx
* const { I18nProvider, useI18n } = makeI18n('landing', 'home');
*
* function App() {
* return (
* <I18nProvider apiUrl="/api/i18n">
* <Home />
* </I18nProvider>
* );
* }
*
* function Home() {
* const i18n = useI18n();
* return <h1>{i18n.hero.title}</h1>;
* }
* ```
*/
export function makeI18n(domain: string, app: string) {
// Create context for this domain/app
const I18nContext = createContext<I18nContextValue | null>(null);
/**
* Inner provider that uses React Query hooks
* Must be wrapped by QueryClientProvider
*/
const I18nProviderInner: FC<{
children: ReactNode;
apiUrl: string;
config: Omit<MakeI18nConfig, 'apiUrl'>;
}> = ({ children, apiUrl, config }) => {
const {
defaultLocale = 'en',
supportedLocales = ['en'],
cacheTime = 24 * 60 * 60 * 1000,
staleTime = Infinity,
debug = false,
customFetch,
} = config;
// Auto-detect route from React Router
const location = useLocation();
const route = location.pathname;
// Language detection
const [searchParams, setSearchParams] = useSearchParams();
const detectedLocale = useMemo(
() => detectLanguage(searchParams, defaultLocale),
[searchParams, defaultLocale],
);
// Change locale handler
const changeLocale = useCallback(
(newLocale: string) => {
if (!supportedLocales.includes(newLocale)) {
console.warn(`[i18n] Locale ${newLocale} not supported, ignoring`);
return;
}
// Update localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('i18n_locale', newLocale);
}
// Update URL query param
setSearchParams((prev: URLSearchParams) => {
const next = new URLSearchParams(prev);
next.set('lang', newLocale);
return next;
});
},
[supportedLocales, setSearchParams],
);
// Fetch translations - now safely inside QueryClientProvider
const { isLoading } = useQuery(
createTranslationQueryOptions(
domain,
app,
route,
detectedLocale,
apiUrl,
customFetch,
{ staleTime, cacheTime },
),
);
const contextValue = useMemo<I18nContextValue>(
() => ({
locale: detectedLocale,
changeLocale,
domain,
app,
route,
isLoading,
apiUrl,
supportedLocales,
debug,
}),
[detectedLocale, changeLocale, route, isLoading, apiUrl, supportedLocales, debug],
);
return <I18nContext.Provider value={contextValue}>{children}</I18nContext.Provider>;
};
/**
* Outer I18nProvider component for this domain/app
* Wraps with QueryClientProvider if needed
*/
const I18nProvider: FC<{
children: ReactNode;
apiUrl: string;
config?: Partial<Omit<MakeI18nConfig, 'apiUrl'>>;
queryClient?: QueryClientType;
}> = ({ children, apiUrl, config = {}, queryClient: externalQueryClient }) => {
const fullConfig = {
defaultLocale: 'en',
supportedLocales: ['en'],
cacheTime: 24 * 60 * 60 * 1000,
staleTime: Infinity,
debug: false,
...config,
};
// Wrap with QueryClientProvider if no external client provided
if (!externalQueryClient) {
const internalQueryClient = useMemo(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: fullConfig.staleTime,
gcTime: fullConfig.cacheTime,
},
},
}), [fullConfig.staleTime, fullConfig.cacheTime]);
return (
<QueryClientProvider client={internalQueryClient}>
<I18nProviderInner apiUrl={apiUrl} config={fullConfig}>
{children}
</I18nProviderInner>
</QueryClientProvider>
);
}
return (
<I18nProviderInner apiUrl={apiUrl} config={fullConfig}>
{children}
</I18nProviderInner>
);
};
/**
* useI18n hook for this domain/app
* Returns a proxy object for dot notation access
*/
function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error(
`useI18n must be used within I18nProvider (domain: ${domain}, app: ${app})`,
);
}
const { locale, route } = context;
const queryClient = useQueryClient();
// Get cached translation data
const queryKey = ['i18n', domain, app, route, locale] as const;
const data = queryClient.getQueryData<TranslationData>(queryKey);
// Create proxy for dot notation access
const proxy = useMemo(
() => createTranslationProxy(data, context.debug),
[data, context.debug],
);
return proxy;
}
/**
* useI18nContext hook - access context directly
*/
function useI18nContext() {
const context = useContext(I18nContext);
if (!context) {
throw new Error(
`useI18nContext must be used within I18nProvider (domain: ${domain}, app: ${app})`,
);
}
return context;
}
return {
I18nProvider,
useI18n,
useI18nContext,
};
}
// ============================================================================
// Prefetch Utility
// ============================================================================
/**
* Prefetch translations for a route (for React Router loaders)
*/
export async function prefetchRoute(
queryClient: QueryClientType,
domain: string,
app: string,
route: string,
locale: string,
apiUrl: string,
): Promise<void> {
await queryClient.prefetchQuery(
createTranslationQueryOptions(domain, app, route, locale, apiUrl),
);
}