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:
parent
b83f4b9d6b
commit
8328679aa0
5 changed files with 444 additions and 546 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
298
features/landing/frontend/src/mocks/handlers.ts
Normal file
298
features/landing/frontend/src/mocks/handlers.ts
Normal 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]
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue