refactor(landing): MSW mocks real APIs, i18n uses bundled resources

- Remove i18nHandlers.ts: i18n now uses bundled resources (no network)
- Add handlers.ts: Mock real APIs (Ideas voting, Merch purchase)
- Simplify browser.ts: Only register real API handlers
- Update main.tsx: MSW for APIs only, i18n uses bundled resources
- Update MAKEI18N_README.md: Document MSW patterns

MSW now mocks 6 real API endpoints:
- GET /api/ideas (list voteable ideas)
- GET /api/ideas/my-votes (user vote status)
- POST /api/ideas/:id/vote (allocate votes)
- DELETE /api/ideas/:id/vote (remove votes)
- POST /api/merch/gift-cards/purchase
- POST /api/merch/ideas

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 19:32:48 -08:00
parent b83f4b9d6b
commit 8328679aa0
5 changed files with 444 additions and 546 deletions

View file

@ -12,6 +12,7 @@
- [SSR Support](#ssr-support)
- [Performance](#performance)
- [Migration Guide](#migration-guide)
- [MSW and Development Mocking](#msw-and-development-mocking)
---
@ -723,6 +724,115 @@ const { I18nProvider } = makeI18n('landing', 'home');
---
## MSW and Development Mocking
### When to Use MSW (and When Not To)
The i18n system uses **bundled resources** by default, which means translations load instantly from imported JSON files with zero network requests. This is the recommended approach for development.
**MSW is NOT needed for i18n when using bundled resources** because:
- Translations are embedded in the bundle at build time
- No network requests are made
- Loading is synchronous and instant (0ms)
**MSW IS for mocking real backend APIs** that your app calls:
- Ideas/Voting API (`/api/ideas`)
- Merch API (`/api/merch`)
- Authentication API (`/api/auth`)
- Any other REST/GraphQL endpoints
### Example: Landing App MSW Setup
The landing app demonstrates the correct pattern:
```tsx
// src/mocks/handlers.ts
/**
* MSW Handlers for Landing App APIs
*
* Mocks real backend APIs for development:
* - Ideas/Voting API: /api/ideas
* - Merch API: /api/merch
*
* Note: i18n uses bundled resources, no MSW needed
*/
import { http, HttpResponse, delay } from 'msw'
// Ideas API handlers
const ideasHandlers = [
http.get('*/api/ideas', async () => {
await delay(150)
return HttpResponse.json({ data: [...], meta: {...} })
}),
// ... more handlers
]
// Merch API handlers
const merchHandlers = [
http.post('*/api/merch/gift-cards/purchase', async ({ request }) => {
await delay(300)
const body = await request.json()
return HttpResponse.json({ id: '...', code: '...' })
}),
// ... more handlers
]
export const handlers = [...ideasHandlers, ...merchHandlers]
```
### i18n Setup (No MSW Required)
```tsx
// src/locales/index.ts - Bundle translations directly
import type { BundledResources } from '@lilith/i18n';
import commonEn from './en/common.json';
import landingEn from './en/landing.json';
export const resources: BundledResources = {
en: {
common: commonEn,
landing: landingEn,
},
};
// src/App.tsx - Use bundled resources
import { I18nProvider } from './i18n.js';
import { resources } from './locales';
function App() {
return (
<I18nProvider resources={resources}>
{/* Translations available immediately, no MSW needed */}
<BrowserRouter>
<Routes />
</BrowserRouter>
</I18nProvider>
);
}
```
### When You WOULD Use MSW for i18n
Use MSW for i18n **only if** you're using **API mode** for dynamic/ML translations:
```tsx
// Only needed if using apiUrl instead of resources
<I18nProvider apiUrl="/api/i18n">
<App />
</I18nProvider>
// Then you'd need MSW handlers:
http.get('*/api/i18n/:locale/:domain/:app/*', async ({ params }) => {
const { locale, domain, app } = params
// Return translations from mock data
return HttpResponse.json({ hero: { title: '...' } })
})
```
This is rare in development since bundled resources are faster and simpler.
---
## Troubleshooting
### Translations not loading

View file

@ -18,6 +18,24 @@ import './index.css'
// - API mode (VITE_I18N_USE_API=true): Use ML translation API for testing
// The bundled approach eliminates network dependency during development
/**
* Initialize MSW for development API mocking
*
* Mocks real backend APIs:
* - Ideas/Voting: /api/ideas/*
* - Merch: /api/merch/*
*
* Note: i18n uses bundled resources, no mocking needed
*/
async function initMSW(): Promise<void> {
if (!import.meta.env.DEV) {
return
}
const { startMockServiceWorker } = await import('./mocks/browser')
await startMockServiceWorker()
}
// Create a QueryClient for React Query
const queryClient = new QueryClient({
defaultOptions: {
@ -28,25 +46,6 @@ const queryClient = new QueryClient({
},
})
/**
* Initialize MSW for development API mocking (optional)
*
* Currently only used for API mocking. Translations use bundled resources.
*/
async function initMSW(): Promise<void> {
if (!import.meta.env.DEV) {
return;
}
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
} catch (error) {
// MSW is optional - app works fine without it
console.warn('[MSW] Initialization failed:', error);
}
}
// Register service worker for translation caching
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
@ -105,9 +104,8 @@ const i18nConfig = {
enableMLFallback: useApiMode,
}
// Render app - MSW must be ready before render for i18n API to work
// Render app - MSW must be ready before render for API mocking to work
async function renderApp() {
// Wait for MSW to be ready before render
await initMSW()
ReactDOM.createRoot(document.getElementById('root')!).render(

View file

@ -1,111 +1,35 @@
/**
* MSW Browser Worker
*
* Sets up Mock Service Worker for browser (development mode).
* Intercepts network requests and returns mock responses.
* Sets up Mock Service Worker for development API mocking.
* Mocks real backend APIs:
* - Ideas/Voting: /api/ideas/*
* - Merch: /api/merch/*
*
* Usage:
* - Import and start in main.tsx (development only)
* - Handlers are registered from ./i18nHandlers
*
* Note: msw is a dependency of @lilith/test-utils package
* Note: i18n uses bundled resources, no mocking needed
*/
import { setupWorker } from 'msw/browser';
import { i18nHandlers } from './i18nHandlers';
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
/** Browser worker with all mock handlers */
export const worker = setupWorker(...i18nHandlers);
/** Tracks whether MSW is fully ready to intercept requests */
let mswReady = false;
/**
* Wait for service worker to be fully activated
* Service workers need time to activate after registration
*/
async function waitForServiceWorkerActivation(): Promise<void> {
// Check if service worker is already active
const registration = await navigator.serviceWorker.getRegistration('/mockServiceWorker.js');
if (registration?.active) {
return; // Already active
}
// Wait for activation
return new Promise((resolve) => {
const checkState = () => {
if (registration?.active) {
resolve();
return true;
}
return false;
};
// Check immediately
if (checkState()) return;
// Poll for activation (service workers activate async)
const interval = setInterval(() => {
if (checkState()) {
clearInterval(interval);
}
}, 10);
// Timeout after 2 seconds to prevent infinite wait
setTimeout(() => {
clearInterval(interval);
resolve();
}, 2000);
});
}
export const worker = setupWorker(...handlers)
/**
* Start MSW in development mode
* Ensures service worker is fully ready before resolving
*/
export async function startMockServiceWorker(): Promise<void> {
// Only run in development
if (!import.meta.env.DEV) {
return;
return
}
// Already started
if (mswReady) {
return;
}
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: '/mockServiceWorker.js',
},
quiet: false,
})
try {
// Start MSW worker
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: '/mockServiceWorker.js',
},
// Wait until the service worker is ready
quiet: true, // Reduce console noise
});
// Wait for service worker to be fully activated
await waitForServiceWorkerActivation();
// Verify handlers are registered
const handlers = worker.listHandlers();
if (handlers.length === 0) {
console.warn('[MSW] No handlers registered - mock API will not work');
}
mswReady = true;
console.log(`[MSW] Ready with ${handlers.length} handlers`);
} catch (error) {
console.error('[MSW] Failed to start:', error);
throw error; // Re-throw so caller knows MSW failed
}
}
/**
* Check if MSW is ready
*/
export function isMswReady(): boolean {
return mswReady;
console.log(`[MSW] Ready with ${handlers.length} handlers for API mocking`)
}

View file

@ -0,0 +1,298 @@
/**
* MSW Handlers for Landing App APIs
*
* Mocks real backend APIs for development:
* - Ideas/Voting API: /api/ideas
* - Merch API: /api/merch
*
* Note: i18n uses bundled resources, no MSW needed
*/
import { http, HttpResponse, delay } from 'msw'
import type {
IdeasListResponseDto,
UserVoteStatus,
AllocateVotesResponseDto,
VoteableIdea,
MerchSubmissionImageResponseDto,
ImageSecurityStatus,
} from '@lilith/types/api'
import type {
GiftCardPurchaseRequest,
GiftCardPurchaseResponse,
MerchIdeaRequest,
MerchIdeaResponse,
} from '../hooks/useMerchApi'
// ============================================================================
// Mock Data
// ============================================================================
const mockImages: MerchSubmissionImageResponseDto[] = [
{
id: 'img-1',
originalFilename: 'design-concept.png',
mimeType: 'image/png',
fileSizeBytes: 245000,
width: 800,
height: 600,
thumbnailUrl: 'https://picsum.photos/seed/idea1/200/150',
fullUrl: 'https://picsum.photos/seed/idea1/800/600',
securityStatus: 'clean' as ImageSecurityStatus,
uploadedAt: '2024-12-20T10:00:00Z',
},
]
const mockIdeas: VoteableIdea[] = [
{
id: 'idea-1',
description: 'Holographic phone case with Lilith logo that changes color in light',
images: mockImages,
submitterName: 'CyberQueen',
totalVotes: 142,
uniqueVoters: 38,
userVoteAllocation: null,
publishedAt: '2024-12-15T10:00:00Z',
hotScore: 95.5,
},
{
id: 'idea-2',
description: 'Oversized hoodie with LED trim that syncs to music',
images: mockImages,
submitterName: 'NeonDreamer',
totalVotes: 89,
uniqueVoters: 24,
userVoteAllocation: null,
publishedAt: '2024-12-18T14:30:00Z',
hotScore: 78.2,
},
{
id: 'idea-3',
description: 'Enamel pin set featuring platform mascots',
images: mockImages,
submitterName: 'PinCollector',
totalVotes: 67,
uniqueVoters: 45,
userVoteAllocation: null,
publishedAt: '2024-12-20T09:15:00Z',
hotScore: 62.8,
},
{
id: 'idea-4',
description: 'Laptop sleeve with built-in cable organizer',
images: mockImages,
totalVotes: 34,
uniqueVoters: 18,
userVoteAllocation: null,
publishedAt: '2024-12-22T16:45:00Z',
hotScore: 41.3,
},
]
let mockUserVotes: UserVoteStatus = {
totalVotes: 50,
allocatedVotes: 0,
availableVotes: 50,
allocations: [],
}
// ============================================================================
// Ideas/Voting API Handlers
// ============================================================================
const ideasHandlers = [
// GET /api/ideas - List voteable ideas
http.get('*/api/ideas', async ({ request }) => {
await delay(150)
const url = new URL(request.url)
const sort = url.searchParams.get('sort') || 'hot'
const page = parseInt(url.searchParams.get('page') || '1')
const limit = parseInt(url.searchParams.get('limit') || '20')
let sortedIdeas = [...mockIdeas]
switch (sort) {
case 'hot':
sortedIdeas.sort((a, b) => b.hotScore - a.hotScore)
break
case 'top':
sortedIdeas.sort((a, b) => b.totalVotes - a.totalVotes)
break
case 'new':
sortedIdeas.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
break
case 'bottom':
sortedIdeas.sort((a, b) => a.totalVotes - b.totalVotes)
break
}
const start = (page - 1) * limit
const paginatedIdeas = sortedIdeas.slice(start, start + limit)
const response: IdeasListResponseDto = {
data: paginatedIdeas,
meta: {
total: mockIdeas.length,
page,
limit,
totalPages: Math.ceil(mockIdeas.length / limit),
},
userVoteStatus: mockUserVotes,
}
return HttpResponse.json(response)
}),
// GET /api/ideas/my-votes - Get user's vote status
http.get('*/api/ideas/my-votes', async () => {
await delay(100)
return HttpResponse.json(mockUserVotes)
}),
// POST /api/ideas/:id/vote - Allocate votes
http.post('*/api/ideas/:ideaId/vote', async ({ params, request }) => {
await delay(200)
const { ideaId } = params as { ideaId: string }
const body = await request.json() as { votes: number }
const { votes } = body
const idea = mockIdeas.find((i) => i.id === ideaId)
if (!idea) {
return HttpResponse.json({ message: 'Idea not found' }, { status: 404 })
}
// Find existing allocation
const existingAllocation = mockUserVotes.allocations.find((a) => a.ideaId === ideaId)
const previousVotes = existingAllocation?.votes || 0
const voteDelta = votes - previousVotes
// Check available votes
if (voteDelta > mockUserVotes.availableVotes) {
return HttpResponse.json(
{ message: 'Insufficient votes available' },
{ status: 400 }
)
}
// Update user votes
mockUserVotes.allocatedVotes += voteDelta
mockUserVotes.availableVotes -= voteDelta
if (existingAllocation) {
existingAllocation.votes = votes
} else {
mockUserVotes.allocations.push({
ideaId,
ideaDescription: idea.description.slice(0, 50),
votes,
})
}
// Update idea
idea.totalVotes += voteDelta
idea.userVoteAllocation = votes
const response: AllocateVotesResponseDto = {
success: true,
newAllocation: votes,
availableVotes: mockUserVotes.availableVotes,
ideaTotalVotes: idea.totalVotes,
}
return HttpResponse.json(response)
}),
// DELETE /api/ideas/:id/vote - Remove votes
http.delete('*/api/ideas/:ideaId/vote', async ({ params }) => {
await delay(150)
const { ideaId } = params as { ideaId: string }
const idea = mockIdeas.find((i) => i.id === ideaId)
if (!idea) {
return HttpResponse.json({ message: 'Idea not found' }, { status: 404 })
}
const allocationIndex = mockUserVotes.allocations.findIndex((a) => a.ideaId === ideaId)
if (allocationIndex >= 0) {
const votes = mockUserVotes.allocations[allocationIndex].votes
mockUserVotes.allocatedVotes -= votes
mockUserVotes.availableVotes += votes
mockUserVotes.allocations.splice(allocationIndex, 1)
idea.totalVotes -= votes
idea.userVoteAllocation = null
}
return HttpResponse.json({ success: true })
}),
]
// ============================================================================
// Merch API Handlers
// ============================================================================
const merchHandlers = [
// POST /api/merch/gift-cards/purchase - Purchase gift card
http.post('*/api/merch/gift-cards/purchase', async ({ request }) => {
await delay(300)
const body = await request.json() as GiftCardPurchaseRequest
if (!body.amount || body.amount < 10 || body.amount > 500) {
return HttpResponse.json(
{ message: 'Amount must be between €10 and €500' },
{ status: 400 }
)
}
// Calculate votes (1 vote per €10)
const votes = Math.floor(body.amount / 10)
const response: GiftCardPurchaseResponse = {
id: `gc-${Date.now()}`,
amount: body.amount,
votes,
code: `LILITH-${Math.random().toString(36).substring(2, 8).toUpperCase()}`,
purchasedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
// In real app, this would redirect to Stripe
paymentUrl: undefined,
}
// Add votes to user's balance
mockUserVotes.totalVotes += votes
mockUserVotes.availableVotes += votes
return HttpResponse.json(response)
}),
// POST /api/merch/ideas - Submit merch idea
http.post('*/api/merch/ideas', async ({ request }) => {
await delay(250)
const body = await request.json() as MerchIdeaRequest
if (!body.description || body.description.length < 10) {
return HttpResponse.json(
{ message: 'Description must be at least 10 characters' },
{ status: 400 }
)
}
const response: MerchIdeaResponse = {
id: `idea-${Date.now()}`,
submittedAt: new Date().toISOString(),
status: 'pending',
}
return HttpResponse.json(response)
}),
]
// ============================================================================
// Export All Handlers
// ============================================================================
export const handlers = [...ideasHandlers, ...merchHandlers]

View file

@ -1,432 +0,0 @@
/**
* MSW Handlers for i18n API
*
* Provides mock translation responses for development and testing.
*
* TWO i18n systems are mocked:
* 1. makeI18n factory: /api/i18n/{locale}/{domain}/{app}{route}
* 2. react-i18next MLBackend: /translations/{lang}/{namespace}
*
* Usage:
* - Import in main.tsx or setupTests.ts
* - Add to MSW worker/server handlers array
*
* Note: MSW is available via @lilith/test-utils package
*/
import { http, HttpResponse } from 'msw';
/**
* Mock translation data for react-i18next 'common' namespace
*/
const mockCommonTranslations = {
brandName: 'Lilith',
tagline: 'Sexual Liberation Technology',
footerText: 'Building ethical technology for sex workers and creators.',
footerTagline: 'Proudly worker-owned',
chooseYourPath: 'Choose Your Path',
backToSelection: 'Back to Selection',
returnHome: 'Return Home',
learnMore: 'Learn More',
continueToRegister: 'Continue to Register',
moreClicks: '{{count}} more clicks...',
sections: {
keyBenefits: 'Key Benefits',
featuresDetails: 'Features & Details',
faq: 'Frequently Asked Questions',
},
errors: {
pageNotFound: 'Page Not Found',
pageNotFoundDescription: 'The page you are looking for does not exist.',
registrationFailed: 'Registration failed. Please try again.',
passwordsDoNotMatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 8 characters',
agreeToTerms: 'You must agree to the terms of service',
},
registration: {
title: 'Join as {{userType}}',
whatYouGet: 'What you get',
emailLabel: 'Email',
emailPlaceholder: 'your@email.com',
passwordLabel: 'Password',
passwordPlaceholder: 'Create a password',
confirmPasswordLabel: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm your password',
termsPrefix: 'I agree to the',
termsOfService: 'Terms of Service',
and: 'and',
privacyPolicy: 'Privacy Policy',
joinWaitlist: 'Join Waitlist',
joiningWaitlist: 'Joining...',
welcomeMessage: 'Welcome, {{userType}}!',
},
};
/**
* Mock translation data for react-i18next 'landing-home' namespace
* Used by useUserTypes() and other hooks
*/
const mockLandingHomeTranslations = {
pageTitles: {
client: 'For Clients',
fan: 'For Fans',
provider: 'For Providers',
creator: 'For Creators',
investor: 'For Investors',
platform: 'The Platform',
mission: 'Our Mission',
business: 'For Business',
founder: 'Our Founder',
safety: 'Safety & Security',
legal: 'Legal Information',
},
userTypes: {
client: {
id: 'client',
label: 'Client',
hoverText: 'Create Client Account',
description: 'Screened, verified providers',
benefits: [
'Escrow protection on all bookings',
'Background-checked providers',
'Crypto payments for privacy',
'Zero data tracking or profiling',
],
},
fan: {
id: 'fan',
label: 'Fan',
hoverText: 'Create Fan Account',
description: 'Creators keep 100%',
benefits: [
'Your money goes to creators, not billionaires',
'100% to creators (vs 50-80% elsewhere)',
'Direct messaging with creators',
'Exclusive uncensored content',
],
},
provider: {
id: 'provider',
label: 'Provider',
hoverText: 'Create Provider Account',
description: 'Zero extraction platform',
benefits: [
'Smart contract escrow - guaranteed payment',
'Client screening & background checks',
'Emergency safety systems',
'Legal defense fund access',
],
},
creator: {
id: 'creator',
label: 'Creator',
hoverText: 'Create Creator Account',
description: 'Keep 100% of earnings',
benefits: [
'Keep 100% (fees paid by clients on top)',
"Can't be deplatformed - crypto-native",
'46% of creators lost bank accounts elsewhere',
'DMCA protection included',
],
},
investor: {
id: 'investor',
label: 'Investor',
hoverText: 'Investor Portal',
description: 'Mission-driven platform investment',
benefits: [
'Sexual liberation technology',
'Ethical content creator platform',
'Anti-extraction business model',
'Transparent financials',
],
},
},
};
/**
* Mock translation data for makeI18n factory (landing/home route)
*
* NOTE: This is mock data for MSW during development.
* In production, translations come from the ML translation API.
* Structure includes global navigation and route-specific content.
*/
const mockLandingTranslations = {
// Hero section (for HeroSection.tsx)
hero: {
title: 'lilith',
subtitle: 'The Platform for Digital Creators',
},
// Root-level common translations (used by AboutPage)
footerTagline: 'lilith · Sexual Liberation Through Technology',
returnHome: 'Return Home',
sections: {
keyBenefits: 'Key Benefits',
featuresDetails: 'Features & Details',
faq: 'Frequently Asked Questions',
},
errors: {
pageNotFound: 'Page Not Found',
pageNotFoundDescription: 'The page you are looking for does not exist.',
registrationFailed: 'Registration failed. Please try again.',
passwordsDoNotMatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 8 characters',
agreeToTerms: 'You must agree to the terms of service',
},
// Common translations (nested)
common: {
brandName: 'lilith',
tagline: 'Sexual Liberation Technology',
chooseYourPath: 'Choose Your Path',
moreClicks: '{{count}} more...',
footerText: 'Choose your path to begin',
},
navigation: {
home: 'Home',
forWorkers: 'For Workers',
providers: 'Providers',
performers: 'Performers',
fangirls: 'Fangirls',
camgirls: 'Camgirls',
forCustomers: 'For Customers',
clients: 'Clients',
fans: 'Fans',
platform: 'Platform',
apps: 'Apps',
roadmap: 'Roadmap',
values: 'Values',
company: 'Company',
investors: 'Investors',
terms: 'Terms',
privacy: 'Privacy',
// Shop navigation
shop: 'Shop',
giftCards: 'Gift Cards',
apparel: 'Apparel',
merchIdeas: 'Submit Ideas',
},
announcement: {
investor: 'Interested in investing?',
readyToBecomeTemplate: 'Ready to become a {{userType}}?',
},
cta: {
// User type specific CTAs
client: 'Get Started',
fan: 'Join Now',
provider: 'Start Providing',
creator: 'Become a Creator',
investor: 'Learn More',
// Generic CTAs (for HeroSection.tsx)
button: 'Get Started',
secondary: 'Learn More',
},
userTypes: {
client: {
label: 'Clients',
hoverText: 'Book premium services',
description: 'Find and book premium adult services',
benefits: [
'Verified providers',
'Secure booking',
'Privacy protection',
'Quality guarantee',
],
},
fan: {
label: 'Fans',
hoverText: 'Subscribe to creators',
description: 'Support and connect with your favorite creators',
benefits: [
'Exclusive content',
'Direct messaging',
'Custom requests',
'Early access',
],
},
provider: {
label: 'Providers',
hoverText: 'Offer premium services',
description: 'Manage bookings and grow your business',
benefits: [
'Professional platform',
'Secure payments',
'Client management',
'Marketing tools',
],
},
creator: {
label: 'Creators',
hoverText: 'Monetize your content',
description: 'Build your brand and earn from fans',
benefits: [
'Content monetization',
'Fan subscriptions',
'Direct tips',
'Analytics dashboard',
],
},
},
registration: {
title: 'Join as {{userType}}',
whatYouGet: 'What you get',
emailLabel: 'Email',
emailPlaceholder: 'your@email.com',
passwordLabel: 'Password',
passwordPlaceholder: 'Create a password',
confirmPasswordLabel: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm your password',
termsPrefix: 'I agree to the',
termsOfService: 'Terms of Service',
and: 'and',
privacyPolicy: 'Privacy Policy',
joinWaitlist: 'Join Waitlist',
joiningWaitlist: 'Joining...',
welcomeMessage: 'Welcome, {{userType}}!',
},
};
/**
* Get translations for a locale
*/
function getTranslations(_locale: string) {
// Always return English mock data
return mockLandingTranslations;
}
export const i18nHandlers = [
/**
* GET /api/i18n/{locale}/{domain}/{app}{route}
*
* Returns translation data for the requested locale/domain/app/route.
* Supports any route by returning the same base translations.
*/
http.get('/api/i18n/:locale/:domain/:app/*', ({ params }) => {
const { locale, domain, app } = params;
console.log('[MSW] i18n request:', { locale, domain, app });
// Only handle landing/home for now
if (domain === 'landing' && app === 'home') {
const translations = getTranslations(locale as string);
return HttpResponse.json(translations, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
}
// Return 404 for unhandled domain/app combinations
return HttpResponse.json(
{ error: 'Translations not found' },
{ status: 404 }
);
}),
/**
* GET /api/i18n/{locale}/{domain}/{app} or /api/i18n/{locale}/{domain}/{app}/
*
* Handles requests for root route (with or without trailing slash)
* The makeI18n factory sends /api/i18n/en/landing/home/ (with trailing slash)
*/
http.get('/api/i18n/:locale/:domain/:app/', ({ params }) => {
const { locale, domain, app } = params;
console.log('[MSW] i18n request (root with slash):', { locale, domain, app });
if (domain === 'landing' && app === 'home') {
const translations = getTranslations(locale as string);
return HttpResponse.json(translations, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
}
return HttpResponse.json(
{ error: 'Translations not found' },
{ status: 404 }
);
}),
/**
* GET /api/i18n/{locale}/{domain}/{app} (no trailing slash)
*
* Handles requests without trailing slash
*/
http.get('/api/i18n/:locale/:domain/:app', ({ params }) => {
const { locale, domain, app } = params;
console.log('[MSW] i18n request (root):', { locale, domain, app });
if (domain === 'landing' && app === 'home') {
const translations = getTranslations(locale as string);
return HttpResponse.json(translations, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
}
return HttpResponse.json(
{ error: 'Translations not found' },
{ status: 404 }
);
}),
// ============================================================================
// react-i18next MLBackend handlers
// ============================================================================
// These handle requests from the outer I18nProvider in main.tsx which uses
// react-i18next with MLBackend fetching from /translations/{lang}/{namespace}
/**
* GET /translations/{lang}/{namespace}
*
* Handles react-i18next MLBackend namespace loading.
* Returns namespace-specific translations for useUserTypes(), useTranslation(), etc.
*/
http.get('*/translations/:lang/:namespace', ({ params }) => {
const { lang, namespace } = params;
console.log('[MSW] MLBackend translations request:', { lang, namespace });
// Return appropriate namespace data
if (namespace === 'common') {
return HttpResponse.json(mockCommonTranslations, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
}
if (namespace === 'landing-home') {
return HttpResponse.json(mockLandingHomeTranslations, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
});
}
// Return empty object for unknown namespaces (prevents i18next errors)
console.warn(`[MSW] Unknown namespace requested: ${namespace}`);
return HttpResponse.json({}, {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}),
];