AI Prompt: Forma3D.Connect — Internationalization & Per-User Locale Preferences¶
Purpose: Implement full i18n infrastructure with per-user locale preferences covering language, timezone, date/number formatting, and measurement units (metric/imperial)
Estimated Effort: 24–32 hours (Phase 1 foundation: database + backend + frontend + English string extraction + Settings UI)
Prerequisites: Multi-tenancy RBAC phase completed (User + Tenant models exist in Prisma schema)
Output: Two-tier locale system (tenant defaults + user overrides), react-i18next integration, locale-aware formatting hooks, locale settings UI, backend locale middleware, ESLint enforcement
Research: internationalization-research.md
Status: 🚧 TODO
🎯 Mission¶
Implement internationalization (i18n) and per-user locale preferences in Forma3D.Connect, enabling the platform to serve users in multiple languages with configurable timezone, date format, number format, and measurement units.
What this delivers:
- Per-user locale preferences stored in the database with a two-tier resolution chain (user → tenant → system default)
- react-i18next integration with namespace-based lazy loading and typed translation keys
- Locale-aware formatting hooks for dates, times, numbers, currencies, and measurements (metric/imperial)
- Backend locale middleware that resolves the user's locale for emails and push notifications
- Settings UI where users can configure language, timezone, date format, time format, measurement system, and first day of week
- ESLint enforcement to prevent new hardcoded strings in JSX after extraction
- CI translation completeness check to catch missing translations before merge
Why this matters:
Forma3D.Connect targets European 3D print-on-demand businesses. Expanding beyond the Belgian/Dutch market requires the UI, emails, and notifications to work in the user's language with their regional formatting conventions. Per-user (not just per-tenant) preferences are essential because tenants may have employees across language regions (e.g., a Belgian company with Flemish and Walloon staff).
Critical constraints:
- This prompt covers Phase 1 only: English (default) + Dutch, i18n infrastructure, and locale preferences
- Dutch translations should use placeholder keys initially (marked
[NL] ...) — professional translation is a separate task - All internal storage remains UTC for timestamps and metric for dimensions — conversion is display-layer only
- The API continues returning locale-neutral data (ISO dates, numbers as numbers, dimensions in mm)
- No breaking changes to existing API contracts
📌 Context (Current State)¶
What Exists¶
| Aspect | Current Implementation | Location |
|---|---|---|
| User model | id, email, passwordHash, firstName, lastName, isActive, lastLoginAt, tenantId, userRoles, auditLogs — no locale fields |
prisma/schema.prisma lines 133–153 |
| Tenant model | id, name, slug, isDefault, isActive — no locale defaults |
prisma/schema.prisma lines 97–131 |
| Date formatting | toLocaleDateString('en-US', ...) — hardcoded English |
libs/utils/src/lib/date.ts |
| Relative time | Custom English strings ("just now", "X minutes ago") | libs/utils/src/lib/date.ts |
| Email dates | toLocaleString('en-BE', { dateStyle: 'medium', timeStyle: 'short' }) — hardcoded |
apps/order-service/src/notifications/email.service.ts (and print-service, shipping-service equivalents) |
| Theme context | ThemeProvider in apps/web/src/contexts/theme-context.tsx stores light/dark/system in localStorage — good pattern to follow for locale |
apps/web/src/contexts/theme-context.tsx |
| Settings page | Theme, grid pricing, print settings, stock management, push notifications, integrations, developer tools — no locale section | apps/web/src/pages/settings/index.tsx |
| App providers | AuthProvider → SocketProvider → ThemeProvider → ServiceWorkerProvider → RouterProvider |
apps/web/src/app.tsx |
| Hooks directory | 20+ custom hooks (use-orders.ts, use-dashboard.ts, etc.) — well-established pattern |
apps/web/src/hooks/ |
| SystemConfig | Tenant-scoped key-value store (used for feature flags, grid pricing) — not suitable for per-user preferences | prisma/schema.prisma lines 381–395 |
| Units | Metric only (mm, cm) throughout — correct as internal storage | Multiple files |
What's Missing¶
- No i18n library installed — no
react-i18next,i18next, ornestjs-i18ninpackage.json - No locale fields on User or Tenant — cannot store preferences
- No preference resolution service — no concept of "resolved locale"
- All UI strings are hardcoded English — ~980 strings across pages, components, and shared UI
- No locale-aware formatting — dates, numbers, and currencies use hardcoded locales
- No measurement conversion — only metric (mm, cm, g, kg) with no imperial option
- No ESLint rule preventing new hardcoded strings
🛠️ Tech Stack Reference¶
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite 7, Tailwind CSS, TanStack Query, React Router |
| Backend | NestJS 11 (microservices: gateway, order-service, print-service, shipping-service) |
| Database | PostgreSQL, Prisma 5 |
| Date library | date-fns (currently used — keep for date arithmetic, replace display formatting with Intl API) |
| Nodemailer + Handlebars templates | |
| Monorepo | Nx + pnpm |
| Testing | Vitest (frontend), Jest (backend) |
📋 Step-by-Step Implementation¶
Phase 1: Database Schema — Locale Preferences (1 hour)¶
Priority: P0 | Impact: Foundation | Dependencies: None
1. Add locale fields to the User model¶
Edit prisma/schema.prisma. Add the following nullable fields to the User model (null means "inherit from tenant"):
model User {
// ... existing fields (id, tenantId, email, passwordHash, firstName, lastName, isActive, lastLoginAt, createdAt, updatedAt) ...
// i18n preferences (null = inherit from tenant default)
locale String? // BCP 47 language tag: "en", "nl", "fr", "de"
timezone String? // IANA timezone: "Europe/Brussels", "America/New_York"
dateFormat String? // "DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"
timeFormat String? // "24h", "12h"
measurementSystem String? // "metric", "imperial"
firstDayOfWeek Int? // 0 = Sunday, 1 = Monday
// ... existing relations ...
}
2. Add locale defaults to the Tenant model¶
Add the following fields with defaults to the Tenant model:
model Tenant {
// ... existing fields ...
// i18n defaults (applied when a user has not set a specific preference)
defaultLocale String @default("en")
defaultTimezone String @default("UTC")
defaultDateFormat String @default("DD/MM/YYYY")
defaultTimeFormat String @default("24h")
defaultMeasurementSystem String @default("metric")
defaultFirstDayOfWeek Int @default(1)
// ... existing relations ...
}
3. Create and apply the migration¶
pnpm nx run prisma-schema:migrate --name add-locale-preferences
Verify the migration SQL adds the correct columns with the expected defaults.
Phase 2: Backend — Preference Resolution Service (2–3 hours)¶
Priority: P0 | Impact: High | Dependencies: Phase 1
1. Create the UserPreferencesService in the gateway¶
Create apps/gateway/src/user-preferences/ with:
user-preferences.module.tsuser-preferences.controller.tsuser-preferences.service.tsdto/user-preferences.dto.tsdto/update-user-preferences.dto.tsdto/update-tenant-defaults.dto.ts
The service must implement the preference resolution chain:
User preference (if not null)
└── Tenant default (if set)
└── System default: { locale: "en", timezone: "UTC", dateFormat: "DD/MM/YYYY", timeFormat: "24h", measurementSystem: "metric", firstDayOfWeek: 1 }
Each preference is resolved independently — a user might override locale to "nl" but inherit timezone from the tenant.
2. Define the response DTO¶
interface ResolvedUserPreferences {
locale: string;
timezone: string;
dateFormat: string;
timeFormat: string;
measurementSystem: 'metric' | 'imperial';
firstDayOfWeek: number;
source: {
locale: 'user' | 'tenant' | 'system';
timezone: 'user' | 'tenant' | 'system';
dateFormat: 'user' | 'tenant' | 'system';
timeFormat: 'user' | 'tenant' | 'system';
measurementSystem: 'user' | 'tenant' | 'system';
firstDayOfWeek: 'user' | 'tenant' | 'system';
};
}
The source field tells the frontend which tier each value came from, so the Settings UI can show "(inherited from organization)" or "(custom)".
3. Implement API endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/users/me/preferences |
Authenticated user | Returns resolved preferences with source information |
PATCH |
/api/v1/users/me/preferences |
Authenticated user | Updates the user's locale preferences. Send null for any field to reset it to the tenant default. |
PATCH |
/api/v1/tenants/:id/defaults |
Admin only | Updates the tenant's default locale preferences. |
4. Add input validation¶
locale: must be one of["en", "nl", "fr", "de", "es", "it"]timezone: must be a valid IANA timezone (validate againstIntl.supportedValuesOf('timeZone')on Node 18+)dateFormat: must be one of["DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"]timeFormat: must be one of["24h", "12h"]measurementSystem: must be one of["metric", "imperial"]firstDayOfWeek: must be0or1
5. Create the locale resolution middleware¶
Create apps/gateway/src/middleware/locale.middleware.ts:
This middleware runs on every authenticated request and attaches the resolved locale to the request object (req.resolvedLocale, req.resolvedTimezone). It uses the same resolution chain as the preferences service but is optimized for per-request use (can cache the resolved preferences for the duration of the request).
Priority for the middleware:
1. User's DB locale field (if not null)
2. Accept-Language request header (parsed, first match against supported languages)
3. Tenant's defaultLocale field
4. System default: "en"
Phase 3: Frontend — i18n Infrastructure (3–4 hours)¶
Priority: P0 | Impact: High | Dependencies: Phase 2
1. Install dependencies¶
pnpm add i18next react-i18next i18next-http-backend i18next-browser-languagedetector
2. Create the i18n initialization¶
Create apps/web/src/i18n/index.ts:
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'nl'],
ns: ['common', 'dashboard', 'orders', 'printjobs', 'shipments', 'settings', 'errors', 'onboarding'],
defaultNS: 'common',
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'forma3d-locale',
},
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
react: {
useSuspense: true,
},
});
export default i18n;
3. Create the locale file structure¶
apps/web/public/locales/
├── en/
│ ├── common.json # Nav, buttons, labels, shared UI, status badges
│ ├── dashboard.json # Dashboard page
│ ├── orders.json # Orders list + detail
│ ├── printjobs.json # Print jobs
│ ├── shipments.json # Shipments
│ ├── settings.json # Settings page
│ ├── errors.json # Validation and API errors
│ └── onboarding.json # Onboarding wizard
└── nl/
├── common.json # [NL] placeholders initially
├── dashboard.json
├── orders.json
├── printjobs.json
├── shipments.json
├── settings.json
├── errors.json
└── onboarding.json
For Dutch translations, use the format "[NL] Original English text" as placeholders. This makes untranslated strings visually obvious during QA while keeping the app functional.
4. Create the LocaleProvider context¶
Create apps/web/src/contexts/locale-context.tsx:
Follow the same pattern as theme-context.tsx. The provider:
- Fetches resolved preferences from
GET /api/v1/users/me/preferenceson mount (via TanStack Query) - Exposes
locale,timezone,dateFormat,timeFormat,measurementSystem,firstDayOfWeek - Provides an
updatePreferencefunction that callsPATCH /api/v1/users/me/preferencesand optimistically updates the context - Syncs the active language with
i18next.changeLanguage(locale)whenever the locale changes - Updates
document.documentElement.langandlocalStoragewhen the locale changes
interface LocaleContextValue {
locale: string;
timezone: string;
dateFormat: string;
timeFormat: string;
measurementSystem: 'metric' | 'imperial';
firstDayOfWeek: number;
isLoading: boolean;
updatePreference: (key: string, value: string | number | null) => Promise<void>;
}
5. Wire the provider into apps/web/src/app.tsx¶
Add <LocaleProvider> inside <AuthProvider> (needs auth context) and outside <RouterProvider> (routes need locale context):
<AuthProvider>
<SocketProvider>
<ThemeProvider>
<LocaleProvider> ← NEW
<ServiceWorkerProvider>
<RouterProvider router={router} />
</ServiceWorkerProvider>
</LocaleProvider> ← NEW
</ThemeProvider>
</SocketProvider>
</AuthProvider>
6. Import i18n initialization in apps/web/src/main.tsx¶
Add import './i18n'; before the App import to ensure i18next initializes before any component renders.
Phase 4: Locale-Aware Formatting Hooks (2–3 hours)¶
Priority: P0 | Impact: High | Dependencies: Phase 3
1. Create apps/web/src/hooks/use-locale.ts¶
A thin hook that reads from LocaleContext:
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) throw new Error('useLocale must be used within LocaleProvider');
return context;
}
2. Create apps/web/src/hooks/use-format-date.ts¶
Replace the hardcoded date formatting in libs/utils/src/lib/date.ts with a hook-based approach using the Intl.DateTimeFormat API:
formatDate(date)— date only, using user's locale and timezoneformatDateTime(date)— date + time, respecting 12h/24h preferenceformatRelativeTime(date)— "2 hours ago", "yesterday", usingIntl.RelativeTimeFormatwith user's localeformatDuration(minutes)— "2h 30m" in user's locale
All functions use the user's resolved timezone for timezone conversion and locale for language-specific formatting.
3. Create apps/web/src/hooks/use-format-number.ts¶
formatNumber(value, options?)— locale-aware number formatting usingIntl.NumberFormatformatCurrency(value, currency?)— locale-aware currency formatting (defaults to EUR)formatPercent(value)— locale-aware percentage formatting
4. Create apps/web/src/hooks/use-measurement.ts¶
formatLength(valueMm)— returns "20.0 cm" (metric) or "7.87 in" (imperial)formatWeight(valueGrams)— returns "120 g" (metric) or "4.23 oz" (imperial)toMm(value, unit)— converts to mm from cm or inchesisMetric— boolean for conditional rendering
All internal storage remains metric. These hooks only convert for display purposes.
5. Update libs/utils/src/lib/date.ts¶
Keep the existing functions for backward compatibility in contexts where hooks can't be used (e.g., utility functions outside React components), but mark them as deprecated with JSDoc @deprecated tags pointing to the new hooks. Add a comment explaining that new code should use the hooks.
Phase 5: English String Extraction (6–8 hours)¶
Priority: P1 | Impact: High | Dependencies: Phase 3
This is the most time-intensive step. Extract all hardcoded English strings from JSX into translation JSON files.
1. Work through the app page by page¶
| Page / Component | Target file | Estimated strings |
|---|---|---|
apps/web/src/pages/dashboard/ |
en/dashboard.json |
~50 |
apps/web/src/pages/orders/ |
en/orders.json |
~80 |
apps/web/src/pages/print-jobs/ |
en/printjobs.json |
~60 |
apps/web/src/pages/shipments/ |
en/shipments.json |
~50 |
apps/web/src/pages/settings/ |
en/settings.json |
~80 |
apps/web/src/pages/login.tsx |
en/common.json |
~10 |
apps/web/src/components/layout/ |
en/common.json |
~30 |
apps/web/src/components/ (shared) |
en/common.json |
~100 |
libs/ui/src/ |
en/common.json |
~50 |
| Status badges, enums, labels | en/common.json |
~70 |
| Total | ~580 strings (core) |
Remaining ~400 strings (error messages, tooltips, confirmation dialogs, empty states) should be extracted incrementally.
2. Use the useTranslation hook in every component¶
import { useTranslation } from 'react-i18next';
function OrdersPage() {
const { t } = useTranslation('orders');
// ...
return <h1>{t('title')}</h1>;
}
3. Handle pluralization with ICU MessageFormat¶
Install i18next-icu for proper plural support:
pnpm add i18next-icu
Use ICU syntax in translation files:
{
"items_count": "{count, plural, =0 {No items} one {# item} other {# items}}",
"orders_count": "{count, plural, =0 {No orders} one {# order} other {# orders}}"
}
4. Handle string interpolation¶
{
"welcome_user": "Welcome, {name}!",
"order_number": "Order #{number}",
"last_updated": "Last updated {time}"
}
{t('welcome_user', { name: user.firstName })}
5. Create Dutch placeholder translations¶
For every English string, create a Dutch placeholder:
// en/common.json
{ "button_save": "Save" }
// nl/common.json
{ "button_save": "[NL] Save" }
The [NL] prefix makes it trivially easy to find untranslated strings visually. A professional translator replaces these later.
Phase 6: Replace Hardcoded Date/Number Formatting (2 hours)¶
Priority: P1 | Impact: Medium | Dependencies: Phase 4
1. Find and replace all hardcoded date formatting calls¶
Search for these patterns across the frontend codebase and replace with the locale-aware hooks:
| Pattern to find | Replace with |
|---|---|
toLocaleDateString('en-US', ...) |
formatDate(date) from useFormatDate() |
toLocaleDateString('en-BE', ...) |
formatDate(date) from useFormatDate() |
toLocaleString('en-BE', ...) |
formatDateTime(date) from useFormatDate() |
formatDate(date) from libs/utils |
formatDate(date) from useFormatDate() |
formatDateTime(date) from libs/utils |
formatDateTime(date) from useFormatDate() |
getRelativeTime(date) from libs/utils |
formatRelativeTime(date) from useFormatDate() |
format(date, 'MMM d') from date-fns (without locale) |
formatDate(date) from useFormatDate() |
2. Replace hardcoded number formatting¶
| Pattern to find | Replace with |
|---|---|
.toFixed(2) for currency display |
formatCurrency(value) from useFormatNumber() |
.toLocaleString() without locale |
formatNumber(value) from useFormatNumber() |
3. Add measurement formatting where dimensions are displayed¶
Find all places where mm/cm values are displayed to users and wrap them with formatLength() from useMeasurement(). Key locations:
- Grid pricing settings (unit size in mm)
- Product dimensions in order details
- Printer bed sizes
- Any 3D model dimensions shown in the UI
Phase 7: Settings UI — Locale Preferences (2–3 hours)¶
Priority: P1 | Impact: High | Dependencies: Phase 3, Phase 4
1. Add a "Language & Region" section to the Settings page¶
Edit apps/web/src/pages/settings/index.tsx. Add a new section (before or after the Theme section) with:
| Setting | UI Element | Options |
|---|---|---|
| Language | Dropdown select | English, Nederlands |
| Timezone | Searchable dropdown | All IANA timezones, showing "City (UTC±X)" format |
| Date format | Radio group or dropdown | DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD (with live preview) |
| Time format | Radio group | 24-hour (14:30), 12-hour (2:30 PM) (with live preview) |
| Measurement system | Radio group | Metric (cm, kg), Imperial (in, lb) |
| First day of week | Dropdown | Monday, Sunday |
2. Show inheritance source¶
For each setting, show a subtle indicator when the value is inherited from the tenant:
Language: [English ▼] (organization default)
Timezone: [Europe/Brussels (UTC+1) ▼] (custom)
Date format: [DD/MM/YYYY ▼] (organization default)
Add a "Reset to default" button/link for each custom setting.
3. Add live preview¶
Show a preview card below the settings that demonstrates the effect of the current preferences:
Preview:
Date: 10/03/2026
Time: 14:30
Date & Time: 10/03/2026 14:30
Number: 1.490,00
Currency: €1.490,00
Length: 20,0 cm
Weight: 120 g
This preview updates instantly when any preference changes (optimistic update).
4. Admin-only: Tenant defaults¶
If the user has admin permissions, show an additional "Organization Defaults" section below the personal preferences, allowing them to set the tenant-level defaults that apply to all users who haven't customized their own.
Phase 8: Backend — Email & Notification Localization (2–3 hours)¶
Priority: P2 | Impact: Medium | Dependencies: Phase 2
1. Update email services to resolve the recipient's locale¶
Modify the email services in all three microservices:
apps/order-service/src/notifications/email.service.tsapps/print-service/src/notifications/email.service.tsapps/shipping-service/src/notifications/email.service.ts
Before sending an email, look up the recipient's resolved locale (user.locale → tenant.defaultLocale → "en") and:
- Select the matching email template (e.g.,
order-confirmation.en.hbsvsorder-confirmation.nl.hbs) - Format dates using
Intl.DateTimeFormat(resolvedLocale, { timeZone: resolvedTimezone }) - Format currencies using
Intl.NumberFormat(resolvedLocale, { style: 'currency', currency: 'EUR' })
2. Create email template variants¶
For each existing Handlebars template, create a Dutch variant. Initially, the Dutch templates can be identical to the English ones (translated later by a professional translator). The mechanism for selecting the correct template must be in place.
Template naming convention:
templates/
├── order-confirmation.en.hbs
├── order-confirmation.nl.hbs
├── shipping-notification.en.hbs
├── shipping-notification.nl.hbs
└── ...
3. Localize push notification text¶
Update the push notification sending logic to resolve the subscriber's locale and use i18n keys for notification titles and bodies:
const title = resolveNotificationText('notifications.order_received.title', {
lng: resolvedLocale,
orderNumber: order.shopifyOrderNumber,
});
Phase 9: ESLint Enforcement & CI (1–2 hours)¶
Priority: P2 | Impact: Medium | Dependencies: Phase 5
1. Install the ESLint plugin¶
pnpm add -D eslint-plugin-i18next
2. Configure the ESLint rule¶
Add to the web app's ESLint config the i18next/no-literal-string rule. Configure it to:
- Error on hardcoded strings in JSX text content
- Ignore
className,data-testid,key,type,name,idattributes - Ignore strings that are purely technical (URLs, CSS classes, enum values)
- Ignore files in
__tests__/directories - Ignore template literal expressions that are clearly not user-facing
3. Add CI translation completeness check¶
Add a CI step (in the Nx pipeline or GitHub Actions) that:
- Runs
i18next-parserto extract all translation keys used in source code - Compares against each enabled locale's JSON files
- Reports missing keys with their source file locations
- Warns (not fails) if coverage drops below 98% — this is a soft gate during Phase 1
Install the parser:
pnpm add -D i18next-parser
Create apps/web/i18next-parser.config.js with the namespace structure and locale configuration.
✅ Validation Checklist¶
Database¶
-
Usermodel has nullable fields:locale,timezone,dateFormat,timeFormat,measurementSystem,firstDayOfWeek -
Tenantmodel has default-valued fields:defaultLocale,defaultTimezone,defaultDateFormat,defaultTimeFormat,defaultMeasurementSystem,defaultFirstDayOfWeek - Migration applies cleanly on a fresh database and on the existing database
- Existing users have
nullfor all locale fields (inheriting tenant defaults) - Existing tenants have sensible defaults (
en,UTC,DD/MM/YYYY,24h,metric,1)
Backend API¶
-
GET /api/v1/users/me/preferencesreturns resolved preferences with source information -
PATCH /api/v1/users/me/preferencesupdates user locale fields and returns the new resolved state - Sending
nullfor a field in the PATCH request resets it to inherit from tenant -
PATCH /api/v1/tenants/:id/defaultsupdates tenant defaults (admin only) - Input validation rejects invalid locales, timezones, date formats, time formats, measurement systems, and first-day-of-week values
- Locale middleware attaches
resolvedLocaleandresolvedTimezoneto the request
Frontend i18n¶
-
react-i18nextinitializes without errors on app load - English locale files load successfully for each namespace
- Dutch locale files load when language is switched to
nl -
useTranslation('namespace')returns the correct translation function - Missing translation keys fall back to English
- Language switch is instant (no page reload) — i18next changes language and all rendered
t()calls update -
document.documentElement.langis updated when language changes
Formatting Hooks¶
-
useFormatDate().formatDate()returns dates formatted with the user's locale and timezone -
useFormatDate().formatDateTime()respects the 12h/24h time format preference -
useFormatDate().formatRelativeTime()returns localized relative time ("2 uur geleden" in Dutch) -
useFormatNumber().formatCurrency()formats EUR with the correct locale convention (€ 1.490,00 for nl-BE) -
useMeasurement().formatLength()returns "20.0 cm" for metric users and "7.87 in" for imperial users -
useMeasurement().formatWeight()returns "120 g" for metric users and "4.23 oz" for imperial users - All hooks return consistent results across the app
Settings UI¶
- Language & Region section appears in the Settings page
- Users can change each preference and the change persists after page refresh
- Inherited preferences show "(organization default)" indicator
- Custom preferences show "Reset to default" option
- Live preview updates immediately when a preference changes
- Admin users see the "Organization Defaults" section
- Non-admin users do not see the "Organization Defaults" section
Email & Notifications¶
- Email services resolve the recipient's locale before selecting a template
- Dates in emails are formatted using the recipient's timezone and locale
- English and Dutch email template variants exist (Dutch can be placeholder initially)
- Push notifications resolve the subscriber's locale for title/body text
String Extraction¶
- No hardcoded user-facing English strings remain in JSX (within the core pages extracted in Phase 5)
- All extracted strings use the
t()function fromuseTranslation() - Pluralization uses ICU MessageFormat syntax
- String interpolation uses i18next
{variable}syntax
ESLint & CI¶
-
eslint-plugin-i18nextis configured and catches new hardcoded strings in JSX -
i18next-parserconfig is present and extracts keys correctly - CI check reports translation completeness per locale
No Regressions¶
- All existing pages render correctly with English locale
- All existing API endpoints return the same data format (locale-neutral)
- Existing date-fns usage in non-display contexts (date arithmetic, API input formatting) is unchanged
- Theme switching still works
- Push notifications still work
- The app does not crash for unauthenticated users (locale context should handle the case where no user is logged in by falling back to system defaults)
🚫 Constraints and Rules¶
MUST DO¶
- Use
react-i18next— do not build a custom i18n solution - Use the
IntlAPI (Intl.DateTimeFormat,Intl.NumberFormat,Intl.RelativeTimeFormat) for display formatting - Store all timestamps as UTC in PostgreSQL — convert to the user's timezone at the display layer only
- Store all dimensions in metric (mm, g) internally — convert to imperial at the display layer only
- Return locale-neutral data from the API (ISO dates, numbers as numbers, dimensions in mm)
- Use the two-tier resolution chain: user → tenant → system default
- Namespace translation files by feature area for lazy loading
- Follow the existing hook pattern in
apps/web/src/hooks/ - Follow the existing context pattern in
apps/web/src/contexts/ - Use DTOs with class-validator for all new API endpoints
- Write unit tests for the preference resolution service, all formatting hooks, and all measurement conversion functions
MUST NOT¶
- Do not translate product names, order data, or any user-generated content — those are data, not UI strings
- Do not change the API response format — the API remains locale-neutral
- Do not install Luxon, Moment.js, or any heavy date library
- Do not store formatted dates or localized strings in the database
- Do not hardcode locale strings in JSX after the extraction — the ESLint rule must catch these
- Do not use
any,ts-ignore, oreslint-disable - Do not modify existing acceptance tests in a way that breaks them — add new locale-specific tests alongside
SHOULD DO¶
- Use
Intl.supportedValuesOf('timeZone')to populate the timezone dropdown with all valid IANA timezones - Group timezones by region (Europe, Americas, Asia, etc.) in the dropdown for usability
- Show timezone offset alongside the city name: "Brussels (UTC+1)"
- Add a "language" field to the login response so the frontend can initialize i18n before rendering the dashboard
- Consider adding the resolved locale to the JWT payload or session to avoid an extra API call on page load
🔄 Rollback Plan¶
All changes are additive and backward-compatible:
- Database fields: All new User fields are nullable; all new Tenant fields have defaults. Removing them requires only a migration to drop the columns — no data loss for existing fields.
- react-i18next: If removed, replace
t()calls with their English string values. Theen/*.jsonfiles serve as a lookup table for this. - Formatting hooks: If removed, revert to the existing
libs/utils/src/lib/date.tsformatters (kept but deprecated). - LocaleProvider: If removed, the ThemeProvider and other providers continue to work independently.
- Settings UI: The locale section can be removed from the Settings page without affecting other settings.
- ESLint rule: Can be disabled or removed without affecting the application.
- Email templates: Dutch templates can be deleted; the service falls back to English.
📚 Key References¶
- Research document:
docs/03-architecture/research/internationalization-research.md— full i18n research with library evaluations, locale dimensions, and detailed architecture - SaaS readiness research §14:
docs/03-architecture/research/saas-launch-readiness-research.md— original i18n plan with language phases and market analysis - Existing theme context:
apps/web/src/contexts/theme-context.tsx— pattern to follow for LocaleProvider - Existing hooks:
apps/web/src/hooks/— pattern to follow for locale-aware hooks - Existing date formatters:
libs/utils/src/lib/date.ts— code to replace with locale-aware versions - Settings page:
apps/web/src/pages/settings/index.tsx— page to extend with locale preferences - App providers:
apps/web/src/app.tsx— where to add LocaleProvider - Email services:
apps/order-service/src/notifications/email.service.ts(and print-service, shipping-service) — services to update for locale-aware emails - Prisma schema:
prisma/schema.prisma— User model (lines 133–153), Tenant model (lines 97–131) - react-i18next docs: https://react.i18next.com/
- Intl.DateTimeFormat (MDN): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat
- Intl.NumberFormat (MDN): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
- eslint-plugin-i18next: https://github.com/edvardchen/eslint-plugin-i18next
- i18next-parser: https://github.com/i18next/i18next-parser
END OF PROMPT
This prompt implements Phase 1 of the internationalization plan: i18n infrastructure, per-user locale preferences, locale-aware formatting, English string extraction, Dutch placeholders, Settings UI, and ESLint enforcement. Phase 2 (French + German translations, translation management tooling) and Phase 3 (additional languages) are separate future prompts that build on this foundation.