Skip to content

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:

  1. Per-user locale preferences stored in the database with a two-tier resolution chain (user → tenant → system default)
  2. react-i18next integration with namespace-based lazy loading and typed translation keys
  3. Locale-aware formatting hooks for dates, times, numbers, currencies, and measurements (metric/imperial)
  4. Backend locale middleware that resolves the user's locale for emails and push notifications
  5. Settings UI where users can configure language, timezone, date format, time format, measurement system, and first day of week
  6. ESLint enforcement to prevent new hardcoded strings in JSX after extraction
  7. 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, auditLogsno locale fields prisma/schema.prisma lines 133–153
Tenant model id, name, slug, isDefault, isActiveno 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 AuthProviderSocketProviderThemeProviderServiceWorkerProviderRouterProvider 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

  1. No i18n library installed — no react-i18next, i18next, or nestjs-i18n in package.json
  2. No locale fields on User or Tenant — cannot store preferences
  3. No preference resolution service — no concept of "resolved locale"
  4. All UI strings are hardcoded English — ~980 strings across pages, components, and shared UI
  5. No locale-aware formatting — dates, numbers, and currencies use hardcoded locales
  6. No measurement conversion — only metric (mm, cm, g, kg) with no imperial option
  7. 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)
Email 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.ts
  • user-preferences.controller.ts
  • user-preferences.service.ts
  • dto/user-preferences.dto.ts
  • dto/update-user-preferences.dto.ts
  • dto/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 against Intl.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 be 0 or 1

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/preferences on mount (via TanStack Query)
  • Exposes locale, timezone, dateFormat, timeFormat, measurementSystem, firstDayOfWeek
  • Provides an updatePreference function that calls PATCH /api/v1/users/me/preferences and optimistically updates the context
  • Syncs the active language with i18next.changeLanguage(locale) whenever the locale changes
  • Updates document.documentElement.lang and localStorage when 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 timezone
  • formatDateTime(date) — date + time, respecting 12h/24h preference
  • formatRelativeTime(date) — "2 hours ago", "yesterday", using Intl.RelativeTimeFormat with user's locale
  • formatDuration(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 using Intl.NumberFormat
  • formatCurrency(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 inches
  • isMetric — 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.ts
  • apps/print-service/src/notifications/email.service.ts
  • apps/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.hbs vs order-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, id attributes
  • 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:

  1. Runs i18next-parser to extract all translation keys used in source code
  2. Compares against each enabled locale's JSON files
  3. Reports missing keys with their source file locations
  4. 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

  • User model has nullable fields: locale, timezone, dateFormat, timeFormat, measurementSystem, firstDayOfWeek
  • Tenant model 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 null for 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/preferences returns resolved preferences with source information
  • PATCH /api/v1/users/me/preferences updates user locale fields and returns the new resolved state
  • Sending null for a field in the PATCH request resets it to inherit from tenant
  • PATCH /api/v1/tenants/:id/defaults updates 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 resolvedLocale and resolvedTimezone to the request

Frontend i18n

  • react-i18next initializes 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.lang is 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 from useTranslation()
  • Pluralization uses ICU MessageFormat syntax
  • String interpolation uses i18next {variable} syntax

ESLint & CI

  • eslint-plugin-i18next is configured and catches new hardcoded strings in JSX
  • i18next-parser config 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 Intl API (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, or eslint-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:

  1. 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.
  2. react-i18next: If removed, replace t() calls with their English string values. The en/*.json files serve as a lookup table for this.
  3. Formatting hooks: If removed, revert to the existing libs/utils/src/lib/date.ts formatters (kept but deprecated).
  4. LocaleProvider: If removed, the ThemeProvider and other providers continue to work independently.
  5. Settings UI: The locale section can be removed from the Settings page without affecting other settings.
  6. ESLint rule: Can be disabled or removed without affecting the application.
  7. 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.