- 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>
496 lines
14 KiB
TypeScript
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),
|
|
);
|
|
}
|