diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ec3c0be..656cfc09 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -137,6 +137,14 @@ jobs:
cd frontend
npx tsc --noEmit
+ # Missing translation keys and unwrapped strings are invisible to tsc, eslint and vitest,
+ # because t() takes a plain string. Without this step they only surface as raw key paths
+ # in front of a user.
+ - name: i18n check
+ run: |
+ cd frontend
+ npm run i18n:check
+
frontend-test:
name: Frontend Tests
runs-on: ubuntu-latest
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b24faa97..43d05770 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,3 +25,27 @@ repos:
language: system
files: ^frontend/.*\.(ts|tsx)$
pass_filenames: false
+
+ # t() takes a plain string, so a missing translation key type-checks, lints and tests
+ # clean, then renders as a raw key path to the user. These three gates are the only
+ # thing that catches it.
+ - id: i18n-keys
+ name: i18n keys resolve
+ entry: bash -c 'cd frontend && node scripts/i18n-keys.mjs'
+ language: system
+ files: ^frontend/(app|components|lib|messages)/
+ pass_filenames: false
+
+ - id: i18n-parity
+ name: i18n locale parity
+ entry: bash -c 'cd frontend && node scripts/i18n-check.mjs'
+ language: system
+ files: ^frontend/messages/
+ pass_filenames: false
+
+ - id: i18n-scan
+ name: i18n no hardcoded strings
+ entry: bash -c 'cd frontend && node scripts/i18n-scan.mjs'
+ language: system
+ files: ^frontend/(app|components|lib)/.*\.tsx?$
+ pass_filenames: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 25cb71c9..80a7bc8a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -142,6 +142,62 @@ export function ItemCard({ item, onSelect }: ItemCardProps) {
}
```
+## Internationalization
+
+Wardrowbe ships in 8 languages. **Every user-visible string must go through `next-intl`.** A PR that
+hardcodes English will be blocked by CI.
+
+Translation files live in `frontend/messages//.json`, split by feature area.
+`en` is the source of truth; every other locale must have exactly the same key set.
+
+```typescript
+import { useTranslations } from 'next-intl';
+
+export function ItemCard({ item }: ItemCardProps) {
+ const t = useTranslations('wardrobe');
+ const tc = useTranslations('common');
+
+ return (
+
+
{t('card.title')}
+
{t('card.wornCount', { count: item.wear_count })}
+
+
+ );
+}
+```
+
+Rules:
+
+- Add new keys to `frontend/messages/en/.json` only. Other locales are filled in by
+ translators; an untranslated key falls back to its English text rather than breaking the page.
+- Reuse `common` for generic UI verbs (save, cancel, delete, loading) and `constants` for domain
+ vocabulary (clothing types, colors, occasions). Do not re-declare them in a feature namespace.
+- Never build a sentence by concatenating fragments around a value. Use one ICU message:
+ `t('feelsLike', { temp })`, not `Feels {temp}`.
+- Use ICU plurals for anything count-dependent:
+ `"itemCount": "{count, plural, one {{count} item} other {{count} items}}"`.
+- `placeholder`, `title`, `aria-label`, `alt` and every `toast.*()` argument are user-visible too.
+
+Before pushing:
+
+```bash
+cd frontend
+npm run i18n:check
+```
+
+That runs three gates, none of which `tsc`, ESLint or Vitest can replace, because `t()` takes a
+plain string and a missing key type-checks perfectly:
+
+| Gate | Catches |
+|------|---------|
+| `i18n:keys` | `t()` calls referencing keys absent from the `en` catalog |
+| `i18n:parity` | locales missing keys, or ICU placeholders dropped in translation |
+| `i18n:scan` | hardcoded user-visible strings in JSX, attributes and toasts |
+
+Adding a language: add it to `SUPPORTED_LOCALES` in `frontend/lib/i18n/locales.ts`, add the same
+list to `backend/app/utils/locale.py`, and create `frontend/messages//`.
+
## Project Structure
### Backend
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
index bc02310d..198dec63 100644
--- a/backend/app/api/users.py
+++ b/backend/app/api/users.py
@@ -9,6 +9,7 @@
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import get_current_user
+from app.utils.locale import SUPPORTED_LOCALES, is_supported_locale
router = APIRouter(prefix="/users/me", tags=["Users"])
@@ -23,6 +24,7 @@ class UserProfileResponse(BaseModel):
display_name: str
avatar_url: str | None = None
timezone: str
+ locale: str
location_lat: float | None = None
location_lon: float | None = None
location_name: str | None = None
@@ -35,6 +37,7 @@ class UserProfileResponse(BaseModel):
class UserProfileUpdate(BaseModel):
display_name: str | None = None
timezone: str | None = None
+ locale: str | None = None
location_lat: Decimal | None = None
location_lon: Decimal | None = None
location_name: str | None = None
@@ -56,6 +59,14 @@ async def update_profile(
) -> UserProfileResponse:
update_data = data.model_dump(exclude_unset=True)
+ # update_data is applied with a blanket setattr below, so an unsupported locale
+ # must be rejected here to prevent it reaching the column.
+ if "locale" in update_data and not is_supported_locale(update_data["locale"]):
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"locale must be one of: {', '.join(SUPPORTED_LOCALES)}",
+ )
+
if "body_measurements" in update_data and update_data["body_measurements"] is not None:
numeric_keys = {"chest", "waist", "hips", "inseam", "height", "weight"}
for key, value in update_data["body_measurements"].items():
@@ -82,6 +93,7 @@ def _user_response(user: User) -> UserProfileResponse:
display_name=user.display_name,
avatar_url=user.avatar_url,
timezone=user.timezone,
+ locale=user.locale,
location_lat=float(user.location_lat) if user.location_lat else None,
location_lon=float(user.location_lon) if user.location_lon else None,
location_name=user.location_name,
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index f087b08f..c9c62cb1 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -10,6 +10,7 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
+from app.utils.locale import DEFAULT_LOCALE
if TYPE_CHECKING:
from app.models.family import Family
@@ -36,6 +37,9 @@ class User(Base):
avatar_url: Mapped[str | None] = mapped_column(String(500))
role: Mapped[str] = mapped_column(String(20), default="member")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
+ locale: Mapped[str] = mapped_column(
+ String(10), default=DEFAULT_LOCALE, server_default=DEFAULT_LOCALE, nullable=False
+ )
# Location for weather
location_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 8))
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 8cd05487..3b217eb3 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -4,12 +4,15 @@
from pydantic import BaseModel, ConfigDict, EmailStr, Field
+from app.utils.locale import DEFAULT_LOCALE
+
class UserBase(BaseModel):
email: EmailStr
display_name: str = Field(..., min_length=1, max_length=100)
avatar_url: str | None = None
timezone: str = Field(default="UTC", max_length=50)
+ locale: str = Field(default=DEFAULT_LOCALE, max_length=10)
location_lat: Decimal | None = Field(None, ge=-90, le=90)
location_lon: Decimal | None = Field(None, ge=-180, le=180)
location_name: str | None = Field(None, max_length=100)
@@ -23,6 +26,7 @@ class UserUpdate(BaseModel):
display_name: str | None = Field(None, min_length=1, max_length=100)
avatar_url: str | None = None
timezone: str | None = Field(None, max_length=50)
+ locale: str | None = Field(None, max_length=10)
location_lat: Decimal | None = Field(None, ge=-90, le=90)
location_lon: Decimal | None = Field(None, ge=-180, le=180)
location_name: str | None = Field(None, max_length=100)
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
index a30bc26a..a56d7a0b 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -39,6 +39,7 @@ async def create(self, user_data: UserCreate) -> User:
display_name=user_data.display_name,
avatar_url=user_data.avatar_url,
timezone=user_data.timezone,
+ locale=user_data.locale,
location_lat=user_data.location_lat,
location_lon=user_data.location_lon,
location_name=user_data.location_name,
diff --git a/backend/app/utils/locale.py b/backend/app/utils/locale.py
new file mode 100644
index 00000000..21cade65
--- /dev/null
+++ b/backend/app/utils/locale.py
@@ -0,0 +1,19 @@
+DEFAULT_LOCALE = "en"
+
+# Order is the order the UI language picker renders; keep it stable.
+SUPPORTED_LOCALES: tuple[str, ...] = (
+ "en",
+ "zh-CN",
+ "zh-TW",
+ "ko",
+ "ja",
+ "fr",
+ "de",
+ "it",
+)
+
+_SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALES)
+
+
+def is_supported_locale(value: object) -> bool:
+ return isinstance(value, str) and value in _SUPPORTED_LOCALE_SET
diff --git a/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py b/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py
new file mode 100644
index 00000000..b9c4d2f4
--- /dev/null
+++ b/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py
@@ -0,0 +1,37 @@
+"""add locale to users
+
+Persists the UI language server-side so the choice follows a user across devices
+instead of living only in a browser cookie. Existing rows are backfilled to 'en'
+via the server_default.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c1a2b3d4e5f6
+Create Date: 2026-07-29 10:00:00.000000
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "d4e5f6a7b8c9"
+down_revision: str | None = "c1a2b3d4e5f6"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column(
+ "locale",
+ sa.String(length=10),
+ server_default="en",
+ nullable=False,
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("users", "locale")
diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py
index 4ec85333..969a6770 100644
--- a/backend/tests/test_users.py
+++ b/backend/tests/test_users.py
@@ -61,6 +61,91 @@ async def test_update_user_location(self, client: AsyncClient, test_user, auth_h
assert float(data["location_lon"]) == pytest.approx(-74.0060, rel=1e-4)
+class TestUserLocale:
+ @pytest.mark.asyncio
+ async def test_default_locale_is_en(self, client: AsyncClient, test_user, auth_headers):
+ response = await client.get("/api/v1/users/me", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.json()["locale"] == "en"
+
+ @pytest.mark.asyncio
+ async def test_update_locale(self, client: AsyncClient, test_user, auth_headers):
+ response = await client.patch(
+ "/api/v1/users/me",
+ json={"locale": "zh-CN"},
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["locale"] == "zh-CN"
+
+ response = await client.get("/api/v1/users/me", headers=auth_headers)
+ assert response.status_code == 200
+ assert response.json()["locale"] == "zh-CN"
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("locale", ["en", "zh-CN", "zh-TW", "ko", "ja", "fr", "de", "it"])
+ async def test_all_supported_locales_accepted(
+ self, client: AsyncClient, test_user, auth_headers, locale
+ ):
+ response = await client.patch(
+ "/api/v1/users/me",
+ json={"locale": locale},
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["locale"] == locale
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("locale", ["xx", "", "en-US-posix", "x" * 11, "EN", "en_US", None])
+ async def test_unsupported_locale_rejected(
+ self, client: AsyncClient, test_user, auth_headers, locale
+ ):
+ response = await client.patch(
+ "/api/v1/users/me",
+ json={"locale": locale},
+ headers=auth_headers,
+ )
+ assert response.status_code == 422
+
+ response = await client.get("/api/v1/users/me", headers=auth_headers)
+ assert response.json()["locale"] == "en"
+
+ @pytest.mark.asyncio
+ async def test_update_locale_with_other_field(
+ self, client: AsyncClient, test_user, auth_headers
+ ):
+ response = await client.patch(
+ "/api/v1/users/me",
+ json={"locale": "ja", "display_name": "Locale User"},
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["locale"] == "ja"
+ assert data["display_name"] == "Locale User"
+
+ @pytest.mark.asyncio
+ async def test_omitting_locale_preserves_existing(
+ self, client: AsyncClient, test_user, auth_headers
+ ):
+ await client.patch("/api/v1/users/me", json={"locale": "de"}, headers=auth_headers)
+
+ response = await client.patch(
+ "/api/v1/users/me",
+ json={"display_name": "Still German"},
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["display_name"] == "Still German"
+ assert data["locale"] == "de"
+
+ @pytest.mark.asyncio
+ async def test_update_locale_unauthorized(self, client: AsyncClient):
+ response = await client.patch("/api/v1/users/me", json={"locale": "fr"})
+ assert response.status_code == 401
+
+
class TestOnboarding:
"""Tests for onboarding completion endpoint."""
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index bc3f7d5d..d16a0f1e 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -40,6 +40,7 @@ RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+COPY --from=builder --chown=nextjs:nodejs /app/messages ./messages
# Entrypoint drops to nextjs (remapped to PUID/PGID when set); when the
# container is started as a non-root uid it execs directly
diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx
index 9047b321..4ea6f16f 100644
--- a/frontend/app/dashboard/analytics/page.tsx
+++ b/frontend/app/dashboard/analytics/page.tsx
@@ -16,6 +16,7 @@ import { Progress } from '@/components/ui/progress';
import { useAnalytics } from '@/lib/hooks/use-analytics';
import Image from 'next/image';
import Link from 'next/link';
+import { useTranslations } from 'next-intl';
function StatCard({
title,
@@ -95,6 +96,7 @@ function LoadingSkeleton() {
}
function ColorBar({ color, percentage }: { color: string; percentage: number }) {
+ const t = useTranslations('analytics');
const colorMap: Record = {
black: 'bg-gray-900',
white: 'bg-gray-100 border',
@@ -128,7 +130,7 @@ function ColorBar({ color, percentage }: { color: string; percentage: number })
- Create or join a family to share your wardrobe experience
+ {t('subtitle')}
@@ -103,18 +106,18 @@ function NoFamilyView() {
- Create Family
+ {t('createFamily')}
- Start a new family and invite members
+ {t('createFamilyDesc')}
{mode === 'create' ? (
@@ -292,27 +297,25 @@ function FamilyView() {
setEditingName(true);
}}
>
- Edit Name
+ {t('editName')}
)}
- Leave Family?
+ {t('leaveConfirm.title')}
- {isAdmin && family.members.length > 1
- ? 'You are an admin. Make sure another member is an admin before leaving, or remove all other members first.'
- : 'Are you sure you want to leave this family?'}
+ {t('leaveConfirm.description', { name: family.name })}
- Cancel
+ {tc('cancel')} leaveFamily.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@@ -320,7 +323,7 @@ function FamilyView() {
{leaveFamily.isPending ? (
) : null}
- Leave
+ {tc('leave')}
@@ -336,15 +339,15 @@ function FamilyView() {
setNewName(e.target.value)}
- placeholder="Family name"
+ placeholder={t('familyNamePlaceholder')}
onKeyDown={(e) => e.key === 'Enter' && handleUpdateName()}
/>
@@ -354,8 +357,8 @@ function FamilyView() {
{/* Invite Code Card */}
- Invite Code
- Share this code with family members to let them join
+ {t('inviteCodeSection.title')}
+ {t('inviteCodeSection.description')}
@@ -284,20 +287,20 @@ function InsightCard({
}
function NoLearningData({ onRecompute, isRefreshing }: { onRecompute: () => void; isRefreshing: boolean }) {
+ const t = useTranslations('learning');
return (
-
No Learning Data Yet
+
{t('noData.title')}
- Start by accepting or rejecting outfit suggestions and rating them.
- The AI will learn from your feedback to make better recommendations.
+ {t('noData.description')}
- Already gave feedback? Click "Compute Now" to process it.
+ {t('noData.alreadyGaveFeedback')}
{profile.has_learning_data
- ? 'The AI learns from your feedback to improve recommendations'
- : 'Start rating outfits to help the AI learn your preferences'}
+ ? t('subtitle')
+ : t('subtitleEmpty')}
@@ -527,9 +533,9 @@ export default function LearningPage() {
- Your Best Combinations
+ {t('bestCombinations.title')}
- Item pairs that you consistently love together
+ {t('bestCombinations.description')}
@@ -547,9 +553,9 @@ export default function LearningPage() {
- Occasion Patterns
+ {t('occasionPatterns.title')}
- What works for different occasions
+ {t('occasionPatterns.description')}
@@ -558,12 +564,12 @@ export default function LearningPage() {
))}
@@ -622,17 +628,17 @@ export default function LearningPage() {
- Suggested Preference Updates
+ {t('suggestedUpdates.title')}
- Based on your feedback, we suggest updating your preferences
+ {t('suggestedUpdates.description')}