feat: add next-intl internationalization with 4 locales#78
Conversation
Add geocoding service to resolve location names to coordinates using Nominatim API. Add frontend location utilities for network-based location detection and reverse geocoding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Move geocoding user-agent configuration to Settings class. Add environment variable overrides for geocoding and network location provider. Update frontend location utilities with configurable URL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Simplify exception handling in geocoding service. Fix unit system reference in settings page body measurements initialization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Integrate next-intl for full i18n support across the frontend: - Add next-intl package and configure middleware + request config - Create locale switcher component with dropdown menu - Extract all hardcoded strings to translation files (967 keys) - Support English, Chinese, French, and Italian locales - Migrate all pages, components, dialogs, and cards to use useTranslations() - Replace hardcoded toast messages, titles, placeholders, and inline text - Add use-translated-constants hook for dynamic locale-aware constants - Add formatWornAgo i18n helper and location error message translations - Update tests with next-intl mock setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace tShared.raw with tShared in formatWornAgo call so ICU params
like {days} are properly interpolated
- Use t('success', { count }) instead of regex-based string manipulation
in generate-pairings-dialog
- Replace t('leaveFamily').split(' ')[0] with t('confirmLeave') in
family leave action button to avoid locale-dependent word splitting
- Add dedicated schedule.dialogDescription translation key instead of
truncating with split('.')[0] in notifications page
- Add outfits.new.saveError key and use it instead of success message
outfitUpdated as error fallback
- Enhance next-intl test mocks to include raw/rich/markup methods
- Remove unused getDaysSinceDateInTimezone import from utils.test.ts
- Type computeWarnings t parameter as (key: string) => string instead
of any
- Fix formatWornAgo t parameter type to match next-intl TranslationValues
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR integrates next-intl into the Next.js frontend to support 4 locales (en/zh/fr/it). It externalizes ~970 user-facing strings per locale, adds a request-config + locale-switcher + cookie-driven locale resolution, threads useTranslations() through every page/component/dialog, and provides hooks for translated CLOTHING_TYPES/COLORS/OCCASIONS. It also adds an i18n-aware formatWornAgo helper, location-error message translations, tests/mocks, and a Dockerfile change to ship messages/.
Changes:
- Wire up
next-intlplugin, request config, message bundles, locale switcher, and provider in the layout. - Migrate hardcoded English in pages, dialogs, cards, toasts, placeholders, and constants to translation keys with namespace-scoped
useTranslations. - Add
formatWornAgo/getGeolocationFailureMessagei18n helpers and supporting tests/mocks.
Reviewed changes
Copilot reviewed 71 out of 72 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/package.json | Adds next-intl dependency. |
| frontend/next.config.js | Wraps Next config with createNextIntlPlugin. |
| frontend/Dockerfile | Copies messages/ into the standalone runtime image. |
| frontend/i18n/request.ts | Cookie-based locale resolution and message loader; duplicates supported-locales list with the switcher. |
| frontend/components/locale-switcher.tsx | Globe dropdown that sets NEXT_LOCALE cookie and triggers a full page reload inside startTransition. |
| frontend/components/ui/dropdown-menu.tsx | New thin Radix dropdown wrapper used by the switcher. |
| frontend/lib/hooks/use-translated-constants.ts | Hooks that map static type/color/occasion constants to translated labels; redundant locale in useMemo deps. |
| frontend/lib/utils.ts | formatWornAgo accepts a t function; default fallback returns key+JSON which is unfriendly outside tests. |
| frontend/lib/location.ts | Network-location resolver and getGeolocationFailureMessage(t). |
| frontend/app/dashboard/page.tsx | Translates the next-scheduled card; dayNames is Sunday=0 but schedule.day_of_week follows Monday=0, producing a wrong label. |
| frontend/app/dashboard/notifications/page.tsx | DAY_KEYS Monday=0 convention preserved; complex inline IIFE in t() params. |
| frontend/app/dashboard/learning/page.tsx | Concatenation antipatterns ({n}{t('paired')}, {p}{t('confident')}) and a button bound to a misleading styleInsights.noData key. |
| frontend/app/dashboard/family/page.tsx | Migrated to useTranslations; t.rich used for plain interpolation, the admin-specific leave warning has been collapsed to a single string, and English member${s} plural remains. |
| frontend/components/add-item-dialog.tsx | Cancel buttons reuse bulk.discardConfirm.keepEditing ("Keep editing"); some <Label>s use keys named *Placeholder. |
| frontend/components/studio/details-panel.tsx | Threads t through computeWarnings; toasts and labels translated. |
| frontend/components/studio/canvas-panel.tsx | Empty-state and other strings translated. |
| frontend/components/outfits/outfit-card.tsx | Helpers accept t: any, losing next-intl typing. |
| frontend/messages/en.json | Source-of-truth English messages; a few keys are misnamed (styleInsights.noData = "New Insights") or designed for unsafe concatenation (paired, confident). |
| frontend/tests/setup.ts | Mocks next-intl and next-intl/server returning identity translation functions. |
| frontend/tests/utils.test.ts | Tests for formatWornAgo with mocked t. |
| frontend/tests/location.test.ts | New tests for network/reverse-geocoding helpers and geolocation error mapping. |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)
frontend/components/add-item-dialog.tsx:476
- This Cancel button on the bulk upload tab also reuses
t('bulk.discardConfirm.keepEditing')(= "Keep editing"). Same issue as the single-tab Cancel — the button should display "Cancel" rather than "Keep editing". The label is only appropriate inside the discard-confirmation AlertDialog at line 574.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <div className="flex justify-end gap-2 pt-2"> | ||
| <Button type="button" variant="outline" onClick={handleCloseRequest}> | ||
| Cancel | ||
| {t('bulk.discardConfirm.keepEditing')} |
| "styleInsights": { | ||
| "title": "Style Insights", | ||
| "description": "What we've learned about your preferences", | ||
| "noData": "New Insights" |
| "paired": "x paired", | ||
| "confident": "% confident", |
| tDays('days.sunday'), tDays('days.monday'), tDays('days.tuesday'), | ||
| tDays('days.wednesday'), tDays('days.thursday'), tDays('days.friday'), | ||
| tDays('days.saturday'), |
| {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.rich('leaveConfirm.description', { name: family.name })} |
| @@ -351,19 +355,19 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { | |||
| </div> | |||
|
|
|||
| <div className="space-y-2"> | |||
| <Label htmlFor="notes">Notes</Label> | |||
| <Label htmlFor="notes">{t('notesPlaceholder')}</Label> | |||
| </div> | ||
| <div className="text-xs text-muted-foreground"> | ||
| {pair.times_paired}x paired | ||
| {pair.times_paired}{t('paired')} |
| </Badge> | ||
| <span className="text-xs text-muted-foreground"> | ||
| {Math.round(insight.confidence * 100)}% confidence | ||
| {Math.round(insight.confidence * 100)}{t('confident')} |
| <Button variant="ghost" size="sm" onClick={handleGenerateInsights}> | ||
| <RefreshCw className="h-4 w-4 mr-1" /> | ||
| New Insights | ||
| {t('styleInsights.noData')} |
| export function useClothingTypes() { | ||
| const t = useTranslations('types.clothingTypes'); | ||
| const locale = useLocale(); | ||
|
|
||
| return useMemo(() => CLOTHING_TYPES.map((ct) => ({ | ||
| ...ct, | ||
| label: t(ct.value), | ||
| })), [t, locale]); | ||
| } | ||
|
|
||
| /** | ||
| * Returns clothing colors with translated names. | ||
| * The `value` and `hex` properties are preserved. | ||
| */ | ||
| export function useClothingColors() { | ||
| const t = useTranslations('types.clothingColors'); | ||
| const locale = useLocale(); | ||
|
|
||
| return useMemo(() => CLOTHING_COLORS.map((cc) => ({ | ||
| ...cc, | ||
| name: t(cc.value), | ||
| })), [t, locale]); | ||
| } | ||
|
|
||
| /** | ||
| * Returns occasions with translated labels. | ||
| * The `value` property is preserved. | ||
| */ | ||
| export function useOccasions() { | ||
| const t = useTranslations('types.occasions'); | ||
| const locale = useLocale(); | ||
|
|
||
| return useMemo(() => OCCASIONS.map((o) => ({ | ||
| ...o, | ||
| label: t(o.value), | ||
| })), [t, locale]); |
|
Closing in favor of #79 which is a standalone i18n PR (no dependency on other PRs). |
Summary
useTranslations()use-translated-constantshook for dynamic locale-aware constantsformatWornAgoi18n helperNo dependency on other PRs. This PR is fully standalone and targets main directly.
Test Plan
npx tsc --noEmit— zero TypeScript errorsnpx vitest run— all tests passnpm run build— production build succeeds🤖 Generated with Claude Code