feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)

* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 12:16:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e50ab44783
commit ad0497ccd6
142 changed files with 18002 additions and 1243 deletions
+427 -22
View File
@@ -1691,13 +1691,24 @@
"type": "string",
"default": "Title not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "TITLE_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -2161,13 +2172,24 @@
"type": "string",
"default": "Title not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "TITLE_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -2547,13 +2569,24 @@
"type": "string",
"default": "Person not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "PERSON_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -3325,13 +3358,24 @@
"type": "string",
"default": "TMDB API key is not configured"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "TMDB_NOT_CONFIGURED"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -3509,13 +3553,24 @@
"type": "string",
"default": "TMDB API key is not configured"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "TMDB_NOT_CONFIGURED"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -3640,13 +3695,24 @@
"type": "string",
"default": "TMDB API key is not configured"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "TMDB_NOT_CONFIGURED"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -4587,13 +4653,24 @@
"type": "string",
"default": "Integration not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "INTEGRATION_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -4753,13 +4830,24 @@
"type": "string",
"default": "Failed to delete backup"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "BACKUP_DELETE_FAILED"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -4813,13 +4901,24 @@
"type": "string",
"default": "Backup not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "BACKUP_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -4912,13 +5011,24 @@
"type": "string",
"default": "Backup restoration failed"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "BACKUP_RESTORE_FAILED"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -5458,13 +5568,24 @@
"type": "string",
"default": "Job not found"
},
"data": {}
"data": {
"type": "object",
"properties": {
"code": {
"const": "JOB_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message"
"message",
"data"
]
},
{
@@ -6037,6 +6158,77 @@
}
}
}
},
"400": {
"description": "400",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "BAD_REQUEST"
},
"status": {
"const": 400
},
"message": {
"type": "string",
"default": "Invalid import file"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "IMPORT_INVALID_FILE"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
@@ -6848,6 +7040,148 @@
}
}
}
},
"400": {
"description": "400",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "BAD_REQUEST"
},
"status": {
"const": 400
},
"message": {
"type": "string",
"default": "Import payload too large"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "IMPORT_PAYLOAD_TOO_LARGE"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
},
"409": {
"description": "409",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "CONFLICT"
},
"status": {
"const": 409
},
"message": {
"type": "string",
"default": "An import is already in progress"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "IMPORT_ALREADY_RUNNING"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
@@ -7132,6 +7466,77 @@
}
}
}
},
"400": {
"description": "400",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "BAD_REQUEST"
},
"status": {
"const": 400
},
"message": {
"type": "string",
"default": "Import cannot be cancelled"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "IMPORT_CANNOT_CANCEL"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [