From 77d5f6b0e9f4525b02004361a36b019950283ea8 Mon Sep 17 00:00:00 2001 From: apple Date: Wed, 13 May 2026 03:51:46 +0100 Subject: [PATCH 1/2] feat: add next-intl internationalization with 4 locales (en/zh/fr/it) 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 - Update tests with next-intl mock setup Co-Authored-By: Claude Opus 4.6 --- frontend/Dockerfile | 1 + frontend/app/dashboard/analytics/page.tsx | 64 +- frontend/app/dashboard/family/feed/page.tsx | 56 +- frontend/app/dashboard/family/page.tsx | 119 +- frontend/app/dashboard/history/page.tsx | 53 +- frontend/app/dashboard/layout.tsx | 4 +- frontend/app/dashboard/learning/page.tsx | 104 +- frontend/app/dashboard/notifications/page.tsx | 193 +-- frontend/app/dashboard/outfits/[id]/page.tsx | 31 +- frontend/app/dashboard/outfits/new/page.tsx | 80 +- frontend/app/dashboard/outfits/page.tsx | 64 +- frontend/app/dashboard/page.tsx | 117 +- frontend/app/dashboard/pairings/page.tsx | 27 +- frontend/app/dashboard/settings/page.tsx | 224 +-- frontend/app/dashboard/suggest/page.tsx | 88 +- frontend/app/dashboard/wardrobe/page.tsx | 107 +- frontend/app/error.tsx | 12 +- frontend/app/invite/page.tsx | 22 +- frontend/app/layout.tsx | 13 +- frontend/app/login/page.tsx | 47 +- frontend/app/not-found.tsx | 13 +- frontend/app/onboarding/page.tsx | 189 +-- frontend/app/page.tsx | 13 +- frontend/components/add-item-dialog.tsx | 101 +- frontend/components/bulk-action-toolbar.tsx | 19 +- frontend/components/color-eyedropper.tsx | 20 +- frontend/components/family-ratings.tsx | 19 +- frontend/components/feedback-dialog.tsx | 60 +- .../components/generate-pairings-dialog.tsx | 26 +- frontend/components/header.tsx | 13 +- frontend/components/image-lightbox.tsx | 4 +- frontend/components/item-detail-dialog.tsx | 156 +- frontend/components/locale-switcher.tsx | 61 + frontend/components/mobile-nav.tsx | 18 +- frontend/components/mobile-sidebar.tsx | 46 +- frontend/components/offline-indicator.tsx | 4 +- frontend/components/outfit-calendar.tsx | 6 +- frontend/components/outfit-history-card.tsx | 35 +- frontend/components/outfit-preview-dialog.tsx | 28 +- frontend/components/outfits/outfit-card.tsx | 29 +- frontend/components/pagination.tsx | 12 +- frontend/components/pairing-card.tsx | 14 +- .../shared/clone-to-lookbook-dialog.tsx | 20 +- frontend/components/shared/item-picker.tsx | 10 +- frontend/components/shared/lineage-card.tsx | 12 +- frontend/components/shared/occasion-chips.tsx | 6 +- frontend/components/sidebar.tsx | 44 +- frontend/components/studio/canvas-panel.tsx | 6 +- frontend/components/studio/details-panel.tsx | 40 +- frontend/components/ui/dropdown-menu.tsx | 51 + frontend/i18n/request.ts | 21 + .../lib/hooks/use-translated-constants.ts | 51 + frontend/lib/utils.ts | 14 +- frontend/messages/en.json | 1291 ++++++++++++++++ frontend/messages/fr.json | 1347 +++++++++++++++++ frontend/messages/it.json | 1347 +++++++++++++++++ frontend/messages/zh.json | 1347 +++++++++++++++++ frontend/next.config.js | 5 +- frontend/package-lock.json | 699 ++++++++- frontend/package.json | 1 + frontend/tests/setup.ts | 9 + frontend/tests/utils.test.ts | 55 +- 62 files changed, 7548 insertions(+), 1140 deletions(-) create mode 100644 frontend/components/locale-switcher.tsx create mode 100644 frontend/components/ui/dropdown-menu.tsx create mode 100644 frontend/i18n/request.ts create mode 100644 frontend/lib/hooks/use-translated-constants.ts create mode 100644 frontend/messages/en.json create mode 100644 frontend/messages/fr.json create mode 100644 frontend/messages/it.json create mode 100644 frontend/messages/zh.json diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2101e6fa..2ec78f7b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -37,6 +37,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 USER nextjs diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx index 9047b321..5c7b3b45 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, @@ -195,14 +196,15 @@ function AcceptanceTrendChart({ data }: { data: { period: string; rate: number; } export default function AnalyticsPage() { + const t = useTranslations('analytics'); const { data, isLoading, isError } = useAnalytics(60); if (isLoading) { return (
-

Analytics

-

Your wardrobe insights and statistics

+

{t('title')}

+

{t('subtitle')}

@@ -212,7 +214,7 @@ export default function AnalyticsPage() { if (isError || !data) { return (
- Failed to load analytics. Please try again. + {t('loadError')}
); } @@ -222,35 +224,35 @@ export default function AnalyticsPage() { return (
-

Analytics

-

Your wardrobe insights and statistics

+

{t('title')}

+

{t('subtitle')}

{/* Stats Cards */}
50 ? 'up' : undefined} />
@@ -261,7 +263,7 @@ export default function AnalyticsPage() { - Insights + {t('insights.title')} @@ -283,13 +285,13 @@ export default function AnalyticsPage() { - Color Distribution + {t('insights.colorDistribution.title')} - Most common colors in your wardrobe + {t('insights.colorDistribution.description')} {color_distribution.length === 0 ? ( -

No color data yet

+

{t('insights.colorDistribution.noData')}

) : (
{color_distribution.slice(0, 8).map((color) => ( @@ -305,13 +307,13 @@ export default function AnalyticsPage() { - Item Types + {t('insights.itemTypes.title')} - Breakdown by clothing type + {t('insights.itemTypes.description')} {type_distribution.length === 0 ? ( -

No items yet

+

{t('insights.itemTypes.noData')}

) : (
{type_distribution.map((type) => ( @@ -335,12 +337,12 @@ export default function AnalyticsPage() { {/* Most Worn */} - Most Worn - Your favorites + {t('insights.mostWorn.title')} + {t('insights.mostWorn.description')} {most_worn.length === 0 ? ( -

Start tracking your outfits!

+

{t('insights.mostWorn.noData')}

) : (
{most_worn.map((item) => ( @@ -354,12 +356,12 @@ export default function AnalyticsPage() { {/* Least Worn */} - Least Worn - Consider wearing these + {t('insights.leastWorn.title')} + {t('insights.leastWorn.description')} {least_worn.length === 0 ? ( -

Keep tracking!

+

{t('insights.leastWorn.noData')}

) : (
{least_worn.map((item) => ( @@ -373,12 +375,12 @@ export default function AnalyticsPage() { {/* Never Worn */} - Never Worn - Time to try these? + {t('insights.neverWorn.title')} + {t('insights.neverWorn.description')} {never_worn.length === 0 ? ( -

All items have been worn!

+

{t('insights.neverWorn.noData')}

) : (
{never_worn.map((item) => ( @@ -394,8 +396,8 @@ export default function AnalyticsPage() { {acceptance_trend.length > 0 && acceptance_trend.some((t) => t.total > 0) && ( - Acceptance Rate Trend - How you've responded to suggestions over time + {t('insights.acceptanceTrend.title')} + {t('insights.acceptanceTrend.description')} diff --git a/frontend/app/dashboard/family/feed/page.tsx b/frontend/app/dashboard/family/feed/page.tsx index bcd42d96..fe99fce8 100644 --- a/frontend/app/dashboard/family/feed/page.tsx +++ b/frontend/app/dashboard/family/feed/page.tsx @@ -24,6 +24,7 @@ import { FamilyRatingForm, FamilyRatingsDisplay } from '@/components/family-rati import { OutfitPreviewDialog } from '@/components/outfit-preview-dialog'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; function getInitials(name: string) { return name @@ -35,25 +36,26 @@ function getInitials(name: string) { } function SourceBadge({ source }: { source: OutfitSource }) { + const t = useTranslations('familyFeed'); const config: Record = { scheduled: { icon: Calendar, - label: 'Scheduled', + label: t('sourceBadges.scheduled'), className: 'bg-primary/10 text-primary border-primary/20', }, on_demand: { icon: Zap, - label: 'On Demand', + label: t('sourceBadges.onDemand'), className: 'bg-orange-500/10 text-orange-600 border-orange-500/20', }, manual: { icon: Edit3, - label: 'Manual', + label: t('sourceBadges.manual'), className: 'bg-purple-500/10 text-purple-600 border-purple-500/20', }, pairing: { icon: Zap, - label: 'Pairing', + label: t('sourceBadges.pairing'), className: 'bg-violet-500/10 text-violet-600 border-violet-500/20', }, }; @@ -79,6 +81,7 @@ function FeedOutfitCard({ memberName: string; onPreview: () => void; }) { + const t = useTranslations('familyFeed'); const [showRatingForm, setShowRatingForm] = useState(false); const myRating = outfit.family_ratings?.find((r) => r.user_id === currentMemberId); @@ -98,7 +101,7 @@ function FeedOutfitCard({ month: 'short', day: 'numeric', year: 'numeric', - }) : 'Lookbook'} + }) : t('lookbook')}
@@ -152,7 +155,7 @@ function FeedOutfitCard({ ))}
- ({outfit.family_rating_count} rating{outfit.family_rating_count !== 1 ? 's' : ''}) + {t('ratingCount', { count: outfit.family_rating_count })}
)} @@ -183,13 +186,13 @@ function FeedOutfitCard({ onClick={() => setShowRatingForm(true)} > - Rate {memberName}'s outfit + {t('rateOutfit', { member: memberName })} ) ) : (
- Your rating: + {t('yourRating')}
{[1, 2, 3, 4, 5].map((star) => ( setShowRatingForm(!showRatingForm)} > - Edit + {t('edit')}
)} @@ -235,12 +238,13 @@ function FeedOutfitCard({ } function NoFamilyState() { + const t = useTranslations('familyFeed'); return (
-

Family Feed

+

{t('title')}

- Browse and rate your family members' outfits + {t('subtitle')}

@@ -248,14 +252,14 @@ function NoFamilyState() {
-

Join a family first

+

{t('noFamily.title')}

- Create or join a family to browse and rate each other's outfits. + {t('noFamily.description')}

@@ -264,6 +268,7 @@ function NoFamilyState() { } function FeedContent() { + const t = useTranslations('familyFeed'); const { data: session } = useSession(); const { data: family, isLoading: familyLoading } = useFamily(); const currentEmail = session?.user?.email; @@ -296,15 +301,15 @@ function FeedContent() {
-

Family Feed

+

{t('title')}

- Browse and rate your family members' outfits + {t('subtitle')}

@@ -313,13 +318,13 @@ function FeedContent() {
-

No other members yet

+

{t('noMembers.title')}

- Invite family members to start browsing and rating each other's outfits. + {t('noMembers.description')}

@@ -332,15 +337,15 @@ function FeedContent() { {/* Header */}
-

Family Feed

+

{t('title')}

- Browse and rate your family members' outfits + {t('subtitle')}

@@ -394,10 +399,9 @@ function FeedContent() { ) : !data || data.outfits.length === 0 ? (
-

No outfits yet

+

{t('noOutfits.title')}

- {selectedMemberInfo?.display_name ?? 'This member'} hasn't received any outfit recommendations yet. - Check back later! + {t('noOutfits.description', { member: selectedMemberInfo?.display_name ?? 'This member' })}

) : ( diff --git a/frontend/app/dashboard/family/page.tsx b/frontend/app/dashboard/family/page.tsx index 1ce7f71a..a75cf4aa 100644 --- a/frontend/app/dashboard/family/page.tsx +++ b/frontend/app/dashboard/family/page.tsx @@ -56,8 +56,10 @@ import { useUpdateFamily, } from '@/lib/hooks/use-family'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; function NoFamilyView() { + const t = useTranslations('family'); const [mode, setMode] = useState<'create' | 'join' | null>(null); const [familyName, setFamilyName] = useState(''); const [inviteCode, setInviteCode] = useState(''); @@ -69,11 +71,11 @@ function NoFamilyView() { if (!familyName.trim()) return; try { await createFamily.mutateAsync(familyName.trim()); - toast.success('Family created!'); + toast.success(t('familyCreated')); setFamilyName(''); setMode(null); } catch (error) { - toast.error('Failed to create family. Please try again.'); + toast.error(t('createFailed')); } }; @@ -81,20 +83,20 @@ function NoFamilyView() { if (!inviteCode.trim()) return; try { await joinFamily.mutateAsync(inviteCode.trim().toUpperCase()); - toast.success('Joined family!'); + toast.success(t('joinedFamilySuccess')); setInviteCode(''); setMode(null); } catch (error) { - toast.error('Invalid invite code. Please check and try again.'); + toast.error(t('invalidInviteCode')); } }; return (
-

Family

+

{t('title')}

- Create or join a family to share your wardrobe experience + {t('description')}

@@ -103,15 +105,15 @@ function NoFamilyView() { - Create Family + {t('createFamily')} - Start a new family and invite members + {t('cardCreateDesc')} {mode === 'create' ? (
- + {createFamily.isPending && } - Create + {t('create')}
) : ( )}
@@ -145,15 +147,15 @@ function NoFamilyView() { - Join Family + {t('joinFamily')} - Join an existing family with an invite code + {t('joinFamilyDesc')} {mode === 'join' ? (
- + {joinFamily.isPending && } - Join + {t('join')}
{joinFamily.isError && (

- Invalid invite code. Please check and try again. + {t('invalidInviteCode')}

)}
) : ( )}
@@ -194,6 +196,7 @@ function NoFamilyView() { } function FamilyView() { + const t = useTranslations('family'); const { data: session } = useSession(); const { data: family, isLoading } = useFamily(); const [copied, setCopied] = useState(false); @@ -236,9 +239,9 @@ function FamilyView() { const handleRegenerateCode = async () => { try { await regenerateCode.mutateAsync(); - toast.success('New invite code generated!'); + toast.success(t('newInviteCodeGenerated')); } catch (error) { - toast.error('Failed to generate new code. Please try again.'); + toast.error(t('generateCodeFailed')); } }; @@ -246,10 +249,10 @@ function FamilyView() { if (!inviteEmail.trim()) return; try { await inviteMember.mutateAsync({ email: inviteEmail.trim(), role: inviteRole }); - toast.success('Invitation sent!'); + toast.success(t('invitationSent')); setInviteEmail(''); } catch (error) { - toast.error('Failed to send invite. Please try again.'); + toast.error(t('sendInviteFailed')); } }; @@ -257,11 +260,11 @@ function FamilyView() { if (!newName.trim()) return; try { await updateFamily.mutateAsync(newName.trim()); - toast.success('Family name updated!'); + toast.success(t('nameUpdated')); setEditingName(false); setNewName(''); } catch (error) { - toast.error('Failed to update name. Please try again.'); + toast.error(t('nameUpdateError')); } }; @@ -292,27 +295,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.rich('leaveConfirm.description', { name: family.name })} - Cancel + {t('cancel')} leaveFamily.mutate()} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" @@ -320,7 +321,7 @@ function FamilyView() { {leaveFamily.isPending ? ( ) : null} - Leave + {t('leaveFamily').split(' ')[0]} @@ -336,15 +337,15 @@ function FamilyView() { setNewName(e.target.value)} - placeholder="Family name" + placeholder={t('familyNamePlaceholder')} onKeyDown={(e) => e.key === 'Enter' && handleUpdateName()} />
@@ -354,8 +355,8 @@ function FamilyView() { {/* Invite Code Card */} - Invite Code - Share this code with family members to let them join + {t('inviteCodeSection.title')} + {t('inviteCodeSection.description')}
@@ -387,8 +388,8 @@ function FamilyView() { {isAdmin && ( - Send Invite - Invite someone by email + {t('sendInvite')} + {t('sendInviteDesc')}
@@ -407,14 +408,14 @@ function FamilyView() { - Member - Admin + {t('roles.member')} + {t('roles.admin')}
@@ -424,8 +425,8 @@ function FamilyView() { {/* Members List */} - Members - People in your family + {t('members.title')} + {t('members.description')}
@@ -444,13 +445,13 @@ function FamilyView() { {member.display_name} {member.email === currentEmail && ( - You + {t('members.you')} )} {member.role === 'admin' && ( - Admin + {t('members.admin')} )}
@@ -469,8 +470,8 @@ function FamilyView() { - Member - Admin + {t('roles.member')} + {t('roles.admin')} @@ -481,18 +482,18 @@ function FamilyView() { - Remove Member? + {t('members.removeConfirm.title')} - Remove {member.display_name} from the family? + {t('members.removeConfirm.description', { name: member.display_name })} - Cancel + {t('cancel')} removeMember.mutate(member.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - Remove + {t('members.removeConfirm.action')} @@ -509,8 +510,8 @@ function FamilyView() { {isAdmin && family.pending_invites.length > 0 && ( - Pending Invites - Invitations that haven't been accepted yet + {t('pendingInvites.title')} + {t('pendingInvites.description')}
@@ -527,7 +528,7 @@ function FamilyView() { {invite.email}
- Expires {new Date(invite.expires_at).toLocaleDateString()} + {t('pendingInvites.expires', { date: new Date(invite.expires_at).toLocaleDateString() })}
@@ -556,15 +557,15 @@ function FamilyView() { - Family Outfits + {t('familyOutfits.title')} - Browse and rate your family members' outfits + {t('familyOutfits.description')} diff --git a/frontend/app/dashboard/history/page.tsx b/frontend/app/dashboard/history/page.tsx index c15553c2..37862186 100644 --- a/frontend/app/dashboard/history/page.tsx +++ b/frontend/app/dashboard/history/page.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'; import { Calendar } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -19,30 +20,29 @@ import { FeedbackDialog } from '@/components/feedback-dialog'; import { OutfitPreviewDialog } from '@/components/outfit-preview-dialog'; import { format, isSameDay, parseISO } from 'date-fns'; -function EmptyHistory() { +function EmptyHistory({ t }: { t: (key: string) => string }) { return (
-

No recommendation history

+

{t('empty.title')}

- Your outfit recommendation history will appear here once you start - receiving suggestions. + {t('empty.description')}

); } -function EmptyDate({ date }: { date: Date }) { +function EmptyDate({ date, t }: { date: Date; t: (key: string, params?: Record) => string }) { return (

- No outfits for {format(date, 'MMMM d, yyyy')} + {t('noOutfitsForDate', { date: format(date, 'MMMM d, yyyy') })}

); @@ -91,6 +91,7 @@ function CalendarSkeleton() { } export default function HistoryPage() { + const t = useTranslations('history'); const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); @@ -131,7 +132,7 @@ export default function HistoryPage() { if (isError) { return (
- Failed to load history. Please try again. + {t('loadError')}
); } @@ -141,9 +142,9 @@ export default function HistoryPage() { {/* Header */}
-

History

+

{t('title')}

- View your past outfit recommendations + {t('subtitle')}

@@ -152,27 +153,27 @@ export default function HistoryPage() {
@@ -206,7 +207,7 @@ export default function HistoryPage() { {format(selectedDate, 'EEEE, MMMM d')}

- {selectedDateOutfits.length} outfit{selectedDateOutfits.length !== 1 ? 's' : ''} + {t('outfitCount', { count: selectedDateOutfits.length })}

)} @@ -214,9 +215,9 @@ export default function HistoryPage() { {isLoading ? ( ) : !data || data.outfits.length === 0 ? ( - + ) : selectedDate && selectedDateOutfits.length === 0 ? ( - + ) : (
{selectedDateOutfits.map((outfit) => ( diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx index 4f927b0b..72f8ed0d 100644 --- a/frontend/app/dashboard/layout.tsx +++ b/frontend/app/dashboard/layout.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Sidebar } from '@/components/sidebar'; import { MobileSidebar } from '@/components/mobile-sidebar'; import { MobileNav } from '@/components/mobile-nav'; @@ -19,6 +20,7 @@ export default function DashboardLayout({ }) { const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); + const t = useTranslations('dashboard'); const { user, isAuthenticated, isLoading, error } = useAuth(); @@ -41,7 +43,7 @@ export default function DashboardLayout({
-

Loading your wardrobe...

+

{t('layout.loading')}

); diff --git a/frontend/app/dashboard/learning/page.tsx b/frontend/app/dashboard/learning/page.tsx index a4bff4ec..3562e8d9 100644 --- a/frontend/app/dashboard/learning/page.tsx +++ b/frontend/app/dashboard/learning/page.tsx @@ -32,6 +32,7 @@ import { import Image from 'next/image'; import Link from 'next/link'; import { useState } from 'react'; +import { useTranslations } from 'next-intl'; function StatCard({ title, @@ -166,6 +167,7 @@ function ColorPreferenceBar({ colorScore }: { colorScore: LearnedColorScore }) { } function ItemPairCard({ pair }: { pair: ItemPair }) { + const t = useTranslations('learning'); const successRate = pair.times_paired > 0 ? Math.round((pair.times_accepted / pair.times_paired) * 100) : 0; @@ -223,7 +225,7 @@ function ItemPairCard({ pair }: { pair: ItemPair }) { {successRate}%
- {pair.times_paired}x paired + {pair.times_paired}{t('paired')}
@@ -237,6 +239,7 @@ function InsightCard({ insight: StyleInsight; onAcknowledge: (id: string) => void; }) { + const t = useTranslations('learning'); const categoryIcons: Record> = { color: Sparkles, style: Heart, @@ -260,7 +263,7 @@ function InsightCard({ @@ -274,7 +277,7 @@ function InsightCard({ {insight.category} - {Math.round(insight.confidence * 100)}% confidence + {Math.round(insight.confidence * 100)}{t('confident')}
@@ -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')}

@@ -319,6 +322,7 @@ function NoLearningData({ onRecompute, isRefreshing }: { onRecompute: () => void } export default function LearningPage() { + const t = useTranslations('learning'); const { data, isLoading, isError } = useLearning(); const recompute = useRecomputeLearning(); const generateInsights = useGenerateInsights(); @@ -347,8 +351,8 @@ export default function LearningPage() {
-

AI Learning

-

How the AI learns from your feedback

+

{t('title')}

+

{t('subtitle')}

@@ -359,7 +363,7 @@ export default function LearningPage() { if (isError || !data) { return (
- Failed to load learning data. Please try again. + {t('loadError')}
); } @@ -370,11 +374,11 @@ export default function LearningPage() {
-

AI Learning

+

{t('title')}

{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')}

{profile.has_learning_data && ( @@ -384,7 +388,7 @@ export default function LearningPage() { disabled={isRefreshing} > - Recompute + {t('recompute')} )}
@@ -396,32 +400,32 @@ export default function LearningPage() { {/* Stats Cards */}
0.5 ? 'up' : undefined} />
@@ -433,13 +437,13 @@ export default function LearningPage() {
- Style Insights + {t('styleInsights.title')} - What we've learned about your preferences + {t('styleInsights.description')}
@@ -462,14 +466,14 @@ export default function LearningPage() { - Learned Color Preferences + {t('colorPreferences.title')} - Colors you tend to accept or reject + {t('colorPreferences.description')} {profile.color_preferences.length === 0 ? (

- Not enough feedback to determine color preferences yet. + {t('colorPreferences.noData')}

) : (
@@ -486,14 +490,14 @@ export default function LearningPage() { - Learned Style Preferences + {t('stylePreferences.title')} - Styles that match your taste + {t('stylePreferences.description')} {profile.style_preferences.length === 0 ? (

- Not enough feedback to determine style preferences yet. + {t('stylePreferences.noData')}

) : (
@@ -527,9 +531,9 @@ export default function LearningPage() { - Your Best Combinations + {t('bestCombinations.title')} - Item pairs that you consistently love together + {t('bestCombinations.description')}
@@ -547,9 +551,9 @@ export default function LearningPage() { - Occasion Patterns + {t('occasionPatterns.title')} - What works for different occasions + {t('occasionPatterns.description')}
@@ -558,12 +562,12 @@ export default function LearningPage() {

{pattern.occasion}

- {Math.round(pattern.success_rate * 100)}% success + {t('weatherPreferences.success', { percent: Math.round(pattern.success_rate * 100) })}
{pattern.preferred_colors.length > 0 && (
- Preferred colors: + {t('preferredColors')}
{pattern.preferred_colors.map((color) => (
- Weather Preferences + {t('weatherPreferences.title')} - How you dress for different conditions + {t('weatherPreferences.description')}
@@ -604,10 +608,10 @@ export default function LearningPage() {

{pref.weather_type}

- ~{pref.preferred_layers.toFixed(1)} layers + {t('weatherPreferences.layers', { count: pref.preferred_layers.toFixed(1) })}

- {Math.round(pref.success_rate * 100)}% success + {t('weatherPreferences.success', { percent: Math.round(pref.success_rate * 100) })}
))} @@ -622,17 +626,17 @@ export default function LearningPage() { - Suggested Preference Updates + {t('suggestedUpdates.title')} - Based on your feedback, we suggest updating your preferences + {t('suggestedUpdates.description')}
{preference_suggestions.suggestions.suggested_favorite_colors && (
- Add to favorite colors: + {t('suggestedUpdates.addToFavorites')}
{preference_suggestions.suggestions.suggested_favorite_colors.map((color) => ( @@ -645,7 +649,7 @@ export default function LearningPage() { )} {preference_suggestions.suggestions.suggested_avoid_colors && (
- Add to colors to avoid: + {t('suggestedUpdates.addToAvoid')}
{preference_suggestions.suggestions.suggested_avoid_colors.map((color) => ( @@ -660,7 +664,7 @@ export default function LearningPage() {
@@ -671,7 +675,7 @@ export default function LearningPage() { {/* Last Updated */} {profile.last_computed_at && (

- Learning profile last updated: {new Date(profile.last_computed_at).toLocaleString()} + {t('lastUpdated')} {new Date(profile.last_computed_at).toLocaleString()}

)} diff --git a/frontend/app/dashboard/notifications/page.tsx b/frontend/app/dashboard/notifications/page.tsx index 36656549..00723ef2 100644 --- a/frontend/app/dashboard/notifications/page.tsx +++ b/frontend/app/dashboard/notifications/page.tsx @@ -61,16 +61,17 @@ import { Schedule, } from '@/lib/hooks/use-notifications'; import { useUserProfile } from '@/lib/hooks/use-user'; -import { OCCASIONS } from '@/lib/types'; - -const DAYS = [ - { value: 0, label: 'Monday' }, - { value: 1, label: 'Tuesday' }, - { value: 2, label: 'Wednesday' }, - { value: 3, label: 'Thursday' }, - { value: 4, label: 'Friday' }, - { value: 5, label: 'Saturday' }, - { value: 6, label: 'Sunday' }, +import { useOccasions } from '@/lib/hooks/use-translated-constants'; +import { useTranslations } from 'next-intl'; + +const DAY_KEYS = [ + { value: 0, key: 'monday' as const }, + { value: 1, key: 'tuesday' as const }, + { value: 2, key: 'wednesday' as const }, + { value: 3, key: 'thursday' as const }, + { value: 4, key: 'friday' as const }, + { value: 5, key: 'saturday' as const }, + { value: 6, key: 'sunday' as const }, ]; const CHANNEL_ICONS: Record = { @@ -98,6 +99,12 @@ function ChannelCard({ onDelete: () => void; testing: boolean; }) { + const t = useTranslations('notifications'); + const channelLabels: Record = { + ntfy: t('channels.types.ntfy'), + mattermost: t('channels.types.mattermost'), + email: t('channels.types.email'), + }; return ( @@ -107,10 +114,10 @@ function ChannelCard({ {CHANNEL_ICONS[setting.channel]}
-

{CHANNEL_LABELS[setting.channel]}

+

{channelLabels[setting.channel] || CHANNEL_LABELS[setting.channel]}

{setting.channel === 'ntfy' && setting.config.topic} - {setting.channel === 'mattermost' && 'Webhook configured'} + {setting.channel === 'mattermost' && t('channels.webhookConfigured')} {setting.channel === 'email' && setting.config.address}

@@ -129,9 +136,9 @@ function ChannelCard({ ) : ( )} - Test + {t('channels.test')} - Priority {setting.priority} + {t('channels.priority', { level: setting.priority })}
- Add Notification Channel + {t('channels.dialog.title')} - Configure a new way to receive outfit recommendations. + {t('channels.dialog.description')}
- +
@@ -279,7 +287,7 @@ function AddChannelDialog({ {channel === 'ntfy' && ( <>
- +
- +

- Subscribe to this topic in your ntfy app + {t('channels.helpers.topicSubscribe')}

- +

- Required if your ntfy server uses authentication + {t('channels.helpers.accessTokenOptional')}

@@ -318,7 +326,7 @@ function AddChannelDialog({ {channel === 'mattermost' && (
- +

- Create an incoming webhook in Mattermost settings + {t('channels.helpers.mattermostWebhook')}

)} {channel === 'email' && (
- + @@ -378,12 +386,14 @@ function ScheduleCard({ onToggleDayBefore: (notify_day_before: boolean) => void; onDelete: () => void; }) { - const day = DAYS.find((d) => d.value === schedule.day_of_week); - const occasion = OCCASIONS.find((o) => o.value === schedule.occasion); + const t = useTranslations('notifications'); + const occasions = useOccasions(); + const day = DAY_KEYS.find((d) => d.value === schedule.day_of_week); + const occasion = occasions.find((o) => o.value === schedule.occasion); // Calculate which day the notification actually comes const notifyDay = schedule.notify_day_before - ? DAYS[(schedule.day_of_week + 6) % 7] // Previous day + ? DAY_KEYS[(schedule.day_of_week + 6) % 7] // Previous day : day; return ( @@ -395,7 +405,7 @@ function ScheduleCard({
-

{day?.label}

+

{day ? t(`days.${day.key}`) : ''}

{schedule.notification_time} - {occasion?.label || schedule.occasion}

@@ -417,12 +427,12 @@ function ScheduleCard({ onCheckedChange={onToggleDayBefore} />
{schedule.notify_day_before && ( - {notifyDay?.label} evening + {notifyDay ? t(`days.${notifyDay.key}`) : ''} {t('schedule.evening')} )}
@@ -445,6 +455,8 @@ function AddScheduleDialog({ onAdd: (data: ScheduleFormData) => Promise; isLoading: boolean; }) { + const t = useTranslations('notifications'); + const occasions = useOccasions(); const [open, setOpen] = useState(false); const [time, setTime] = useState('07:00'); const [occasion, setOccasion] = useState('casual'); @@ -453,8 +465,8 @@ function AddScheduleDialog({ // Calculate which day notification comes on const notifyDay = notifyDayBefore - ? DAYS[(dayOfWeek + 6) % 7] // Previous day - : DAYS.find((d) => d.value === dayOfWeek); + ? DAY_KEYS[(dayOfWeek + 6) % 7] // Previous day + : DAY_KEYS.find((d) => d.value === dayOfWeek); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -488,20 +500,20 @@ function AddScheduleDialog({ - Add Schedule + {t('schedule.addSchedule')} - Set up when you want to receive outfit recommendations. + {t('schedule.description').split('.')[0]}.
- +
- +
- + setSearch(e.target.value)} className="pl-9 h-9" @@ -374,7 +376,7 @@ function OutfitsPageContent() { {listQuery.data && ( - {listQuery.data.total} total + {t('totalCount', { count: listQuery.data.total })} )}
@@ -383,7 +385,7 @@ function OutfitsPageContent() { {view === 'list' ? ( <> {listError ? ( -
Failed to load outfits
+
{t('loadError')}
) : listLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( @@ -392,7 +394,7 @@ function OutfitsPageContent() {
) : outfits.length === 0 ? (
-

{EMPTY_MESSAGES[chip]}

+

{t(EMPTY_KEYS[chip])}

{chip === 'my-looks' && (
)} @@ -453,7 +455,7 @@ function OutfitsPageContent() {
{calendarError ? ( -
Failed to load outfits
+
{t('loadError')}
) : calendarLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( @@ -464,7 +466,7 @@ function OutfitsPageContent() {

- No outfits on this day + {t('calendar.noOutfitsOnDay')}

) : ( @@ -475,13 +477,13 @@ function OutfitsPageContent() { {formatReadableDate(selectedDate)}

- {selectedDayOutfits.length} outfit{selectedDayOutfits.length === 1 ? '' : 's'} + {t('calendar.outfitCount', { count: selectedDayOutfits.length })}

)} {!selectedDate && (

- {calendarOutfits.length} outfit{calendarOutfits.length === 1 ? '' : 's'} this month + {t('calendar.monthlyCount', { count: calendarOutfits.length })}

)}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 5cdd2d5f..1a019eb1 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useSession } from 'next-auth/react'; +import { useTranslations } from 'next-intl'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -40,6 +41,7 @@ import { toast } from 'sonner'; function WeatherCard() { const { data: weather, isLoading, isError } = useWeather(); const { data: prefs } = usePreferences(); + const t = useTranslations('dashboard'); const unit: TempUnit = prefs?.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius'; if (isLoading) { @@ -48,7 +50,7 @@ function WeatherCard() { - Today's Weather + {t('weather.title')} @@ -65,15 +67,15 @@ function WeatherCard() { - Today's Weather + {t('weather.title')}

- Location not set + {t('weather.locationNotSet')}

@@ -85,14 +87,14 @@ function WeatherCard() { - Today's Weather + {t('weather.title')}
{displayValue(weather.temperature, unit)}{tempSymbol(unit)} - feels {displayValue(weather.feels_like, unit)}° + {t('weather.feelsLike', { temp: `${displayValue(weather.feels_like, unit)}°` })}

@@ -101,13 +103,13 @@ function WeatherCard() { {weather.precipitation_chance > 0 && (

- {weather.precipitation_chance}% chance of rain + {t('weather.rainChance', { percent: weather.precipitation_chance })}

)}
@@ -119,22 +121,23 @@ function PendingOutfitsCard() { const { data, isLoading } = usePendingOutfits(2); const acceptOutfit = useAcceptOutfit(); const rejectOutfit = useRejectOutfit(); + const t = useTranslations('dashboard'); const handleAccept = async (id: string) => { try { await acceptOutfit.mutateAsync(id); - toast.success('Outfit accepted'); + toast.success(t('pendingOutfits.accepted')); } catch { - toast.error('Failed to accept outfit'); + toast.error(t('pendingOutfits.acceptFailed')); } }; const handleReject = async (id: string) => { try { await rejectOutfit.mutateAsync(id); - toast.success('Outfit rejected'); + toast.success(t('pendingOutfits.rejected')); } catch { - toast.error('Failed to reject outfit'); + toast.error(t('pendingOutfits.rejectFailed')); } }; @@ -144,7 +147,7 @@ function PendingOutfitsCard() { - Pending Outfits + {t('pendingOutfits.title')} @@ -163,12 +166,12 @@ function PendingOutfitsCard() { - All Caught Up + {t('pendingOutfits.allCaughtUp')}

- No outfits waiting for your response + {t('pendingOutfits.noPending')}

@@ -181,12 +184,12 @@ function PendingOutfitsCard() {
- Pending Outfits + {t('pendingOutfits.title')} {data?.total || pendingOutfits.length} {(data?.total ?? 0) > 2 && ( - View all + {t('pendingOutfits.viewAll')} )}
@@ -255,6 +258,8 @@ function PendingOutfitsCard() { function NextScheduledCard() { const { data: schedules, isLoading } = useSchedules(); + const t = useTranslations('dashboard'); + const tDays = useTranslations('notifications'); const nextSchedule = useMemo(() => { if (!schedules || schedules.length === 0) return null; @@ -288,15 +293,13 @@ function NextScheduledCard() { return closest; }, [schedules]); - const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - if (isLoading) { return ( - Next Scheduled + {t('nextScheduled.title')} @@ -313,13 +316,13 @@ function NextScheduledCard() { - Next Scheduled + {t('nextScheduled.title')} -

No schedules set up

+

{t('nextScheduled.noSchedules')}

@@ -328,14 +331,19 @@ function NextScheduledCard() { const { schedule, daysUntil } = nextSchedule; const timeStr = schedule.notification_time.slice(0, 5); - const dayStr = daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : dayNames[schedule.day_of_week]; + const dayNames = [ + tDays('days.sunday'), tDays('days.monday'), tDays('days.tuesday'), + tDays('days.wednesday'), tDays('days.thursday'), tDays('days.friday'), + tDays('days.saturday'), + ]; + const dayStr = daysUntil === 0 ? t('nextScheduled.today') : daysUntil === 1 ? t('nextScheduled.tomorrow') : dayNames[schedule.day_of_week]; return ( - Next Scheduled + {t('nextScheduled.title')} @@ -346,7 +354,7 @@ function NextScheduledCard() { {schedule.occasion} outfit

{daysUntil === 0 && ( - Coming up + {t('nextScheduled.comingUp')} )}
@@ -355,6 +363,7 @@ function NextScheduledCard() { function NotificationStatusCard() { const { data: settings, isLoading } = useNotificationSettings(); + const t = useTranslations('dashboard'); if (isLoading) { return ( @@ -362,7 +371,7 @@ function NotificationStatusCard() { - Notifications + {t('notifications.title')} @@ -382,13 +391,13 @@ function NotificationStatusCard() { - Notifications + {t('notifications.title')} -

No channels configured

+

{t('notifications.noChannels')}

@@ -401,10 +410,10 @@ function NotificationStatusCard() {
- Notifications + {t('notifications.title')} - Configure + {t('notifications.configure')}
@@ -426,7 +435,7 @@ function NotificationStatusCard() { ))}

- {enabledChannels.length} of {channels.length} active + {t('notifications.activeCount', { active: enabledChannels.length, total: channels.length })}

@@ -435,6 +444,7 @@ function NotificationStatusCard() { function WeeklySummaryCard() { const { data: analytics, isLoading } = useAnalytics(); + const t = useTranslations('dashboard'); if (isLoading) { return ( @@ -442,7 +452,7 @@ function WeeklySummaryCard() { - This Week + {t('weeklySummary.title')} @@ -464,25 +474,25 @@ function WeeklySummaryCard() { - This Week + {t('weeklySummary.title')}

{wardrobe.outfits_this_week}

-

outfits

+

{t('weeklySummary.outfits')}

{wardrobe.acceptance_rate ? `${wardrobe.acceptance_rate}%` : '-'}

-

accepted

+

{t('weeklySummary.accepted')}

{wardrobe.average_rating && (

- Avg rating: {wardrobe.average_rating}/5 + {t('weeklySummary.avgRating')}: {wardrobe.average_rating}/5

)}
@@ -492,6 +502,7 @@ function WeeklySummaryCard() { function InsightsCard() { const { data: analytics, isLoading } = useAnalytics(); + const t = useTranslations('dashboard'); if (isLoading) { return ( @@ -499,7 +510,7 @@ function InsightsCard() { - Insights + {t('insights.title')} @@ -520,7 +531,7 @@ function InsightsCard() {
- Insights + {t('insights.title')} {insights.length > 3 && ( @@ -541,7 +552,7 @@ function InsightsCard() { ) : (

- Add more items and generate outfits to see personalized insights! + {t('insights.empty')}

)} @@ -551,6 +562,7 @@ function InsightsCard() { function FamilyFeedCard() { const { data: family, isLoading } = useFamily(); + const t = useTranslations('dashboard'); if (isLoading) return null; @@ -564,20 +576,20 @@ function FamilyFeedCard() { - Family Outfits + {t('familyFeed.title')} - See what your family is wearing and rate their outfits + {t('familyFeed.description')}
- {memberCount} member{memberCount !== 1 ? 's' : ''} in {family.name} + {t('familyFeed.memberCount', { count: memberCount, name: family.name })}
@@ -587,23 +599,25 @@ function FamilyFeedCard() { } function QuickActionsCard() { + const t = useTranslations('dashboard'); + return ( - Quick Actions - Common tasks to get you started + {t('quickActions.title')} + {t('quickActions.description')} @@ -613,15 +627,16 @@ function QuickActionsCard() { export default function DashboardPage() { const { data: session } = useSession(); + const t = useTranslations('dashboard'); return (

- Welcome back, {session?.user?.name?.split(' ')[0] || 'User'} + {t('welcomeBack', { name: session?.user?.name?.split(' ')[0] || 'User' })}

- Here's what's happening with your wardrobe + {t('subtitle')}

diff --git a/frontend/app/dashboard/pairings/page.tsx b/frontend/app/dashboard/pairings/page.tsx index 0c4934d9..9a4d537d 100644 --- a/frontend/app/dashboard/pairings/page.tsx +++ b/frontend/app/dashboard/pairings/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Sparkles, Layers } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -20,19 +21,18 @@ import { OutfitPreviewDialog } from '@/components/outfit-preview-dialog'; import { Pairing } from '@/lib/types'; import { Outfit } from '@/lib/hooks/use-outfits'; -function EmptyPairings() { +function EmptyPairings({ t }: { t: (key: string) => string }) { return (
-

No pairings yet

+

{t('empty.title')}

- Select an item from your wardrobe and use “Find Pairings” to discover - outfit combinations that work well together. + {t('empty.description')}

); @@ -68,6 +68,7 @@ function LoadingSkeleton() { } export default function PairingsPage() { + const t = useTranslations('pairings'); const [page, setPage] = useState(1); const [sourceType, setSourceType] = useState(undefined); const [feedbackOutfit, setFeedbackOutfit] = useState(null); @@ -84,7 +85,7 @@ export default function PairingsPage() { if (isError) { return (
- Failed to load pairings. Please try again. + {t('loadError')}
); } @@ -96,10 +97,10 @@ export default function PairingsPage() {

- Pairings + {t('title')}

- AI-generated outfit combinations built around your items + {t('subtitle')}

@@ -108,10 +109,10 @@ export default function PairingsPage() {
{data && (

- {data.total} pairing{data.total !== 1 ? 's' : ''} + {t('pairingCount', { count: data.total })}

)}
@@ -130,7 +131,7 @@ export default function PairingsPage() { {isLoading ? ( ) : !data || data.pairings.length === 0 ? ( - + ) : ( <>
@@ -151,7 +152,7 @@ export default function PairingsPage() { variant="outline" onClick={() => setPage((p) => p + 1)} > - Load More + {t('loadMore')}
)} diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index b5797346..0028b8a9 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -19,9 +19,11 @@ import { import { Badge } from '@/components/ui/badge'; import { usePreferences, useUpdatePreferences, useResetPreferences, useTestAIEndpoint } from '@/lib/hooks/use-preferences'; import { useUserProfile, useUpdateUserProfile } from '@/lib/hooks/use-user'; -import { CLOTHING_COLORS, OCCASIONS, Preferences, StyleProfile, AIEndpoint } from '@/lib/types'; +import { Preferences, StyleProfile, AIEndpoint } from '@/lib/types'; +import { useClothingColors, useOccasions } from '@/lib/hooks/use-translated-constants'; import { toF, toCelsius } from '@/lib/temperature'; import { toast } from 'sonner'; +import { useTranslations } from 'next-intl'; const CM_TO_IN = 0.393701; const IN_TO_CM = 2.54; @@ -46,13 +48,6 @@ const BODY_MEASUREMENT_FIELDS = [ { key: 'inseam', unitMetric: 'cm', unitImperial: 'in', placeholderMetric: 'e.g. 81', placeholderImperial: 'e.g. 32' }, ] as const; -const SIZE_FIELDS = [ - { key: 'shirt_size', label: 'Shirt Size', placeholder: 'e.g. M, L, XL' }, - { key: 'pants_size', label: 'Pants Size', placeholder: 'e.g. 32, 34' }, - { key: 'dress_size', label: 'Dress Size', placeholder: 'e.g. 8, 10' }, - { key: 'shoe_size', label: 'Shoe Size', placeholder: 'e.g. 10, 42' }, -] as const; - function getErrorMessage(e: unknown, fallback: string): string { if (e instanceof Error) return e.message; return fallback; @@ -75,6 +70,8 @@ function ColorPicker({ onChange: (colors: string[]) => void; label: string; }) { + const clothingColors = useClothingColors(); + const toggleColor = (color: string) => { if (selected.includes(color)) { onChange(selected.filter((c) => c !== color)); @@ -87,7 +84,7 @@ function ColorPicker({
- {CLOTHING_COLORS.map((color) => { + {clothingColors.map((color) => { const isSelected = selected.includes(color.value); return (
@@ -497,17 +496,17 @@ export default function SettingsPage() { {/* Account Section */} - Account - Your profile information + {t('account.title')} + {t('account.description')}
- +
- +
@@ -519,15 +518,15 @@ export default function SettingsPage() { - Location + {t('location.title')} - Set your location for weather-based outfit recommendations + {t('location.description')}
- + setLocationName(e.target.value)} @@ -536,7 +535,7 @@ export default function SettingsPage() {
- +
- +
- +
- +
- {SIZE_FIELDS.map((field) => ( -
- + {Object.entries({ + shirt_size: t('body.sizeFields.shirtSize'), + pants_size: t('body.sizeFields.pantsSize'), + dress_size: t('body.sizeFields.dressSize'), + shoe_size: t('body.sizeFields.shoeSize'), + }).map(([key, label]) => ( +
+ handleMeasurementChange(field.key, e.target.value)} - placeholder={field.placeholder} + value={measurements[key] ?? ''} + onChange={(e) => handleMeasurementChange(key, e.target.value)} + placeholder={t(`body.sizePlaceholders.${key}`)} />
))} @@ -681,9 +685,9 @@ export default function SettingsPage() { size="sm" > {updateUserProfile.isPending ? ( - <>Saving... + <>{t('body.saving')} ) : ( - <>Save Measurements + <>{t('body.saveMeasurements')} )} )} @@ -693,19 +697,19 @@ export default function SettingsPage() { {/* Color Preferences */} - Color Preferences + {t('colors.favoriteColors')} - Select colors you love and colors to avoid in recommendations + {t('colors.description')} updateField('color_favorites', colors)} /> updateField('color_avoid', colors)} /> @@ -715,34 +719,34 @@ export default function SettingsPage() { {/* Style Profile */} - Style Profile + {t('styleProfile.title')} - Adjust how much you prefer each style in outfit recommendations + {t('styleProfile.description')} updateStyleProfile('casual', v)} /> updateStyleProfile('formal', v)} /> updateStyleProfile('sporty', v)} /> updateStyleProfile('minimalist', v)} /> updateStyleProfile('bold', v)} /> @@ -752,15 +756,15 @@ export default function SettingsPage() { {/* Temperature & Comfort */} - Temperature & Comfort + {t('temperature.title')} - Adjust how recommendations adapt to weather + {t('temperature.description')}
- +
- +
- +
@@ -824,7 +828,7 @@ export default function SettingsPage() { return ( <>
- +
- + - Recommendation Settings + {t('recommendations.title')} - Customize how outfit recommendations are generated + {t('recommendations.description')}
- +
- +
- +
- +
@@ -938,16 +942,16 @@ export default function SettingsPage() { - AI Endpoints + {t('aiEndpoints.title')} - Configure AI endpoints for image analysis. Endpoints are tried in order from top to bottom. + {t('aiEndpoints.description')} {(formData.ai_endpoints || []).length === 0 ? (

- No custom endpoints configured. Using default server settings. + {t('aiEndpoints.noEndpoints')}

) : (
@@ -1024,16 +1028,16 @@ export default function SettingsPage() { {/* Status badges and test button */}
- {endpoint.enabled ? 'Active' : 'Disabled'} + {endpoint.enabled ? t('aiEndpoints.active') : t('aiEndpoints.disabled')} {endpointTests[index]?.status === 'connected' && ( - Connected + {t('aiEndpoints.connected')} )} {endpointTests[index]?.status === 'error' && ( - Error + {t('aiEndpoints.error')} )}
@@ -1054,17 +1058,17 @@ export default function SettingsPage() { {endpointTests[index]?.status === 'connected' && endpointTests[index]?.models && (

- {endpointTests[index].models?.length} models available + {endpointTests[index].models?.length} {t('aiEndpoints.modelsAvailable')}

{endpointTests[index].visionModels && endpointTests[index].visionModels!.length > 0 && (

- Vision: {endpointTests[index].visionModels?.slice(0, 3).join(', ')} + {t('aiEndpoints.vision')} {endpointTests[index].visionModels?.slice(0, 3).join(', ')} {(endpointTests[index].visionModels?.length || 0) > 3 && '...'}

)} {endpointTests[index].textModels && endpointTests[index].textModels!.length > 0 && (

- Text: {endpointTests[index].textModels?.slice(0, 3).join(', ')} + {t('aiEndpoints.text')} {endpointTests[index].textModels?.slice(0, 3).join(', ')} {(endpointTests[index].textModels?.length || 0) > 3 && '...'}

)} @@ -1077,7 +1081,7 @@ export default function SettingsPage() { )}
- + { @@ -1090,7 +1094,7 @@ export default function SettingsPage() { />
- + { @@ -1103,7 +1107,7 @@ export default function SettingsPage() { />
- + { @@ -1116,7 +1120,7 @@ export default function SettingsPage() { />
- + { @@ -1149,7 +1153,7 @@ export default function SettingsPage() { }} > - Add Endpoint + {t('aiEndpoints.addEndpoint')} {hasChanges && ( )}
diff --git a/frontend/app/dashboard/suggest/page.tsx b/frontend/app/dashboard/suggest/page.tsx index 48199870..6b635c8a 100644 --- a/frontend/app/dashboard/suggest/page.tsx +++ b/frontend/app/dashboard/suggest/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; +import { useTranslations } from 'next-intl'; import { Briefcase, Shirt, @@ -42,7 +43,8 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { api, ApiError, setAccessToken } from '@/lib/api'; -import { OCCASIONS, Outfit, SuggestRequest } from '@/lib/types'; +import { Outfit, SuggestRequest } from '@/lib/types'; +import { useOccasions } from '@/lib/hooks/use-translated-constants'; import { useWeather, Weather } from '@/lib/hooks/use-weather'; import { usePreferences } from '@/lib/hooks/use-preferences'; import { cn } from '@/lib/utils'; @@ -69,25 +71,25 @@ function getWeatherIcon(condition: string, isDay: boolean) { return isDay ? : ; } -// Get time-based greeting -function getGreeting() { +// Get time-based greeting key +function getGreetingKey(): string { const hour = new Date().getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 17) return 'Good afternoon'; - return 'Good evening'; + if (hour < 12) return 'greeting.morning'; + if (hour < 17) return 'greeting.afternoon'; + return 'greeting.evening'; } -// Get weather-based outfit hint -function getWeatherHint(weather: Weather): string { +// Get weather-based outfit hint key +function getWeatherHintKey(weather: Weather): string { const temp = weather.temperature; const condition = weather.condition.toLowerCase(); - if (weather.precipitation_chance > 50) return 'Bring an umbrella or rain jacket'; - if (temp < 10) return 'Layer up - it\'s quite cold'; - if (temp < 18) return 'A light jacket would be perfect'; - if (temp > 28) return 'Keep it light and breathable'; - if (condition.includes('wind')) return 'Consider something windproof'; - return 'Great weather for any style'; + if (weather.precipitation_chance > 50) return 'weatherHints.rainy'; + if (temp < 10) return 'weatherHints.cold'; + if (temp < 18) return 'weatherHints.mild'; + if (temp > 28) return 'weatherHints.hot'; + if (condition.includes('wind')) return 'weatherHints.windy'; + return 'weatherHints.nice'; } interface WeatherOverride { @@ -95,7 +97,7 @@ interface WeatherOverride { condition: 'sunny' | 'cloudy' | 'rainy'; } -function WeatherCard({ weather, isLoading, temperatureUnit }: { weather?: Weather; isLoading: boolean; temperatureUnit: TempUnit }) { +function WeatherCard({ weather, isLoading, temperatureUnit, t }: { weather?: Weather; isLoading: boolean; temperatureUnit: TempUnit; t: (key: string) => string }) { if (isLoading) { return ( @@ -121,9 +123,9 @@ function WeatherCard({ weather, isLoading, temperatureUnit }: { weather?: Weathe
-

Location not set

+

{t('location.notSet')}

- Set your location in settings for weather-aware suggestions + {t('location.setDescription')}

@@ -165,7 +167,7 @@ function WeatherCard({ weather, isLoading, temperatureUnit }: { weather?: Weathe

- {getWeatherHint(weather)} + {t(getWeatherHintKey(weather))}

@@ -180,9 +182,10 @@ function OccasionChips({ selected: string | null; onSelect: (occasion: string) => void; }) { + const occasions = useOccasions(); return (
- {OCCASIONS.map((occasion) => { + {occasions.map((occasion) => { const config = OCCASION_CONFIG[occasion.value]; return ( )}
@@ -262,13 +267,13 @@ function WeatherOverrideSection({ )} > {c.icon} - {c.label} + {t(c.labelKey)} ))}
{weather && (
- Temperature + {t('weatherOverride.temperature')} void; onTryAnother: () => void; onNewRequest: () => void; + t: (key: string) => string; }) { return (
@@ -322,7 +329,7 @@ function OutfitResult({ )}
@@ -349,7 +356,7 @@ function OutfitResult({
-

Your Outfit

+

{t('yourOutfit')}

{outfit.reasoning && (

{outfit.reasoning}

@@ -405,7 +412,7 @@ function OutfitResult({ {outfit.style_notes && (

- Tip: {outfit.style_notes} + {t('tip')} {outfit.style_notes}

)} @@ -416,11 +423,11 @@ function OutfitResult({
@@ -606,6 +615,7 @@ export default function SuggestPage() { onReject={handleReject} onTryAnother={handleTryAnother} onNewRequest={handleNewRequest} + t={t} /> )}
diff --git a/frontend/app/dashboard/wardrobe/page.tsx b/frontend/app/dashboard/wardrobe/page.tsx index 48019fee..677867ee 100644 --- a/frontend/app/dashboard/wardrobe/page.tsx +++ b/frontend/app/dashboard/wardrobe/page.tsx @@ -28,19 +28,26 @@ import { ItemDetailDialog } from '@/components/item-detail-dialog'; import { BulkActionToolbar, BulkSelection } from '@/components/bulk-action-toolbar'; import { useItems, useItem, useItemTypes, useReanalyzeItem, useBulkDeleteItems, useBulkReanalyzeItems, BulkOperationParams } from '@/lib/hooks/use-items'; import { useUserProfile } from '@/lib/hooks/use-user'; -import { CLOTHING_TYPES, CLOTHING_COLORS, Item } from '@/lib/types'; +import { Item } from '@/lib/types'; +import { useClothingTypes, useClothingColors } from '@/lib/hooks/use-translated-constants'; import { toast } from 'sonner'; import { formatWornAgo, getWornAgoColorClass } from '@/lib/utils'; +import { useTranslations } from 'next-intl'; const SORT_OPTIONS = [ - { label: 'Newest first', value: 'created_at', order: 'desc' as const }, - { label: 'Oldest first', value: 'created_at', order: 'asc' as const }, - { label: 'Recently worn', value: 'last_worn', order: 'desc' as const }, - { label: 'Least recently worn', value: 'last_worn', order: 'asc' as const }, - { label: 'Most worn', value: 'wear_count', order: 'desc' as const }, - { label: 'Least worn', value: 'wear_count', order: 'asc' as const }, - { label: 'Name A–Z', value: 'name', order: 'asc' as const }, - { label: 'Name Z–A', value: 'name', order: 'desc' as const }, + { value: 'created_at', order: 'desc' as const }, + { value: 'created_at', order: 'asc' as const }, + { value: 'last_worn', order: 'desc' as const }, + { value: 'last_worn', order: 'asc' as const }, + { value: 'wear_count', order: 'desc' as const }, + { value: 'wear_count', order: 'asc' as const }, + { value: 'name', order: 'asc' as const }, + { value: 'name', order: 'desc' as const }, +] as const; + +const SORT_LABEL_KEYS = [ + 'newestFirst', 'oldestFirst', 'recentlyWorn', 'leastRecentlyWorn', + 'mostWorn', 'leastWorn', 'nameAZ', 'nameZA', ] as const; function ItemCard({ @@ -58,7 +65,10 @@ function ItemCard({ onClick?: () => void; userTimezone: string; }) { - const colorInfo = CLOTHING_COLORS.find((c) => c.value === item.primary_color); + const t = useTranslations('wardrobe'); + const tShared = useTranslations('shared'); + const clothingColors = useClothingColors(); + const colorInfo = clothingColors.find((c) => c.value === item.primary_color); const isProcessing = item.status === 'processing'; const isError = item.status === 'error'; @@ -107,7 +117,7 @@ function ItemCard({ )} {item.needs_wash && (
-
+
@@ -115,13 +125,13 @@ function ItemCard({ {isProcessing && (
- AI Analyzing... + {t('ai.analyzing')}
)} {isError && (
- Analysis Failed + {t('ai.analysisFailed')} {onRetry && ( )}
@@ -169,16 +179,16 @@ function ItemCard({
{item.last_worn_at ? (

- {formatWornAgo(item.last_worn_at, userTimezone)} + {formatWornAgo(item.last_worn_at, userTimezone, tShared.raw)}

) : item.wear_count > 0 ? (

- Worn {item.wear_count} time{item.wear_count !== 1 ? 's' : ''} + {t('wearCount', { count: item.wear_count })}

) : null} {item.ai_confidence !== undefined && item.ai_confidence > 0 && item.status === 'ready' && (

- AI completeness: {Math.round(item.ai_confidence * 100)}% + {t('ai.completeness', { percent: Math.round(item.ai_confidence * 100) })}

)} @@ -199,19 +209,20 @@ function ItemCardSkeleton() { } function EmptyWardrobe({ onAddClick }: { onAddClick: () => void }) { + const t = useTranslations('wardrobe'); + return (
-

Your wardrobe is empty

+

{t('empty.title')}

- Add your first clothing item to start getting personalized outfit - suggestions. + {t('empty.description')}

); @@ -222,6 +233,8 @@ export default function WardrobePage() { const router = useRouter(); const { data: userProfile } = useUserProfile(); const userTimezone = userProfile?.timezone || 'UTC'; + const t = useTranslations('wardrobe'); + const clothingTypes = useClothingTypes(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [selection, setSelection] = useState({ mode: 'none', @@ -356,13 +369,13 @@ export default function WardrobePage() { const params = getBulkParams(); try { const result = await bulkDelete.mutateAsync(params); - toast.success(`Deleted ${result.deleted} items`); + toast.success(t('bulkActions.deleteSuccess', { count: result.deleted })); if (result.failed > 0) { - toast.error(`Failed to delete ${result.failed} items`); + toast.error(t('bulkActions.deletePartialFailed', { count: result.failed })); } handleClearSelection(); } catch { - toast.error('Failed to delete items'); + toast.error(t('bulkActions.deleteError')); } }; @@ -371,16 +384,16 @@ export default function WardrobePage() { try { const result = await bulkReanalyze.mutateAsync(params); if (result.queued > 20) { - toast.success(`Queued ${result.queued} items for re-analysis. This may take a while.`); + toast.success(t('bulkActions.reanalyzeMany', { count: result.queued })); } else { - toast.success(`Queued ${result.queued} items for re-analysis`); + toast.success(t('bulkActions.reanalyzeQueued', { count: result.queued })); } if (result.failed > 0) { - toast.error(`Failed to queue ${result.failed} items`); + toast.error(t('bulkActions.reanalyzePartialFailed', { count: result.failed })); } handleClearSelection(); } catch { - toast.error('Failed to queue items for re-analysis'); + toast.error(t('bulkActions.reanalyzeError')); } }; @@ -393,26 +406,26 @@ export default function WardrobePage() {
-

My Wardrobe

+

{t('title')}

- {total} item{total !== 1 ? 's' : ''} in your wardrobe + {t('itemCount', { count: total })}

{(processingCount > 0 || errorCount > 0) && (
{processingCount > 0 && ( - {processingCount} analyzing + {t('ai.analyzingCount', { count: processingCount })} )} {errorCount > 0 && ( - {errorCount} failed + {t('ai.failedCount', { count: errorCount })} )}
@@ -420,7 +433,7 @@ export default function WardrobePage() {
@@ -430,7 +443,7 @@ export default function WardrobePage() {
{ setSearch(e.target.value); @@ -454,7 +467,7 @@ export default function WardrobePage() { {SORT_OPTIONS.map((opt, i) => ( - {opt.label} + {t(`sort.${SORT_LABEL_KEYS[i]}`)} ))} @@ -486,13 +499,13 @@ export default function WardrobePage() { }} > - + - All types - {CLOTHING_TYPES.map((t) => ( - - {t.label} + {t('allTypes')} + {clothingTypes.map((type) => ( + + {type.label} ))} @@ -508,7 +521,7 @@ export default function WardrobePage() { }} > - Needs wash + {t('needsWash')} {activeFilterCount > 0 && ( @@ -537,7 +550,7 @@ export default function WardrobePage() { }} > - Clear filters + {t('clearFilters')} )}
@@ -547,14 +560,14 @@ export default function WardrobePage() { {error ? (

- Failed to load items. Please try again. + {t('errors.loadFailed')}

) : isLoading ? ( @@ -567,7 +580,7 @@ export default function WardrobePage() { search || typeFilter !== 'all' || needsWash !== undefined || favoriteFilter !== undefined ? (

- No items found matching your filters. + {t('errors.noItemsFound')}

) : ( diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx index 0b886bba..11ab6c74 100644 --- a/frontend/app/error.tsx +++ b/frontend/app/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useTranslations } from 'next-intl'; export default function GlobalError({ error, @@ -11,6 +12,8 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + const t = useTranslations('errorPage'); + useEffect(() => { console.error('Application error:', error); }, [error]); @@ -21,16 +24,15 @@ export default function GlobalError({
-

Something went wrong

+

{t('title')}

- An unexpected error occurred. Please try again or contact support if - the problem persists. + {t('description')}

- +
{process.env.NODE_ENV === 'development' && (
diff --git a/frontend/app/invite/page.tsx b/frontend/app/invite/page.tsx
index 9e26ed82..c55ae705 100644
--- a/frontend/app/invite/page.tsx
+++ b/frontend/app/invite/page.tsx
@@ -10,14 +10,15 @@ import { Button } from '@/components/ui/button';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 import { useJoinFamilyByToken } from '@/lib/hooks/use-family';
 import { ApiError } from '@/lib/api';
+import { useTranslations } from 'next-intl';
 
-function getErrorMessage(error: unknown): string {
+function getErrorMessage(error: unknown, t: (key: string) => string): string {
   if (error instanceof ApiError) {
-    if (error.status === 404) return 'This invite link is invalid or has expired.';
-    if (error.status === 403) return 'This invite was sent to a different email address.';
-    if (error.status === 409) return 'You are already in a family.';
+    if (error.status === 404) return t('errors.invalidLink');
+    if (error.status === 403) return t('errors.wrongEmail');
+    if (error.status === 409) return t('errors.alreadyInFamily');
   }
-  return 'Something went wrong. Please try again.';
+  return t('errors.default');
 }
 
 function InviteContent() {
@@ -26,6 +27,7 @@ function InviteContent() {
   const { status } = useSession();
   const token = searchParams.get('token');
   const joinByToken = useJoinFamilyByToken();
+  const t = useTranslations('invite');
 
   useEffect(() => {
     if (!token) {
@@ -50,7 +52,7 @@ function InviteContent() {
   const handleAccept = async () => {
     try {
       const result = await joinByToken.mutateAsync(token);
-      toast.success(`Joined ${result.family_name}!`);
+      toast.success(t('joinedFamily', { name: result.family_name }));
       router.push('/dashboard/family');
     } catch {
       // error displayed via joinByToken.error below
@@ -63,15 +65,15 @@ function InviteContent() {
         
           
             
-            Family Invitation
+            {t('title')}
           
           
-            You've been invited to join a family on Wardrowbe
+            {t('description')}
           
         
         
           {joinByToken.isError && (
-            

{getErrorMessage(joinByToken.error)}

+

{getErrorMessage(joinByToken.error, t)}

)}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2ad3b777..7dc0fcab 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages } from 'next-intl/server'; import './globals.css'; import { Providers } from './providers'; @@ -31,15 +33,20 @@ export const viewport: Viewport = { userScalable: false, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const locale = await getLocale(); + const messages = await getMessages(); + return ( - + - {children} + + {children} + ); diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 1eca138b..99f1e59c 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -4,8 +4,11 @@ import { Suspense, useEffect, useState } from 'react'; import { signIn, getProviders, useSession } from 'next-auth/react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; function OIDCLoginButton({ callbackUrl }: { callbackUrl: string }) { + const t = useTranslations('login'); + return ( ); } @@ -23,6 +26,7 @@ function DevLogin({ callbackUrl }: { callbackUrl: string }) { const [email, setEmail] = useState('dev@wardrobe.local'); const [name, setName] = useState('Dev User'); const [isLoading, setIsLoading] = useState(false); + const t = useTranslations('login'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -37,11 +41,11 @@ function DevLogin({ callbackUrl }: { callbackUrl: string }) { return (
- Development Mode - Any credentials accepted + {t('devMode')}
- Signing in... + {t('signingIn')} ) : ( - 'Sign in' + t('title') )} @@ -85,9 +89,11 @@ function DevLogin({ callbackUrl }: { callbackUrl: string }) { } function BackendError({ message }: { message: string }) { + const t = useTranslations('login'); + return (
-

Backend Configuration Error

+

{t('backendError.title')}

{message}

); @@ -100,6 +106,7 @@ function LoginContent() { const error = searchParams.get('error'); const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; const [backendError, setBackendError] = useState(null); + const t = useTranslations('login'); useEffect(() => { if (status === 'authenticated' && session?.accessToken) { @@ -117,9 +124,9 @@ function LoginContent() { } }) .catch(() => { - setBackendError('Unable to connect to backend server. Please check that the backend is running.'); + setBackendError(t('backendError.description')); }); - }, []); + }, [t]); // Show sync error from session (e.g. backend returned 503 during login) const syncError = session?.syncError; @@ -157,13 +164,13 @@ function LoginContent() { {error && !backendError && !syncError && (
- {error === 'OAuthSignin' && 'Error starting authentication'} - {error === 'OAuthCallback' && 'Error during authentication callback'} - {error === 'OAuthCreateAccount' && 'Error creating account'} - {error === 'Callback' && 'Error during callback'} - {error === 'CredentialsSignin' && 'Invalid credentials'} - {error === 'AccessDenied' && 'Access denied'} - {!['OAuthSignin', 'OAuthCallback', 'OAuthCreateAccount', 'Callback', 'CredentialsSignin', 'AccessDenied'].includes(error) && 'An error occurred during sign in'} + {error === 'OAuthSignin' && t('errors.OAuthSignin')} + {error === 'OAuthCallback' && t('errors.OAuthCallback')} + {error === 'OAuthCreateAccount' && t('errors.OAuthCreateAccount')} + {error === 'Callback' && t('errors.Callback')} + {error === 'CredentialsSignin' && t('errors.CredentialsSignin')} + {error === 'AccessDenied' && t('errors.AccessDenied')} + {!['OAuthSignin', 'OAuthCallback', 'OAuthCreateAccount', 'Callback', 'CredentialsSignin', 'AccessDenied'].includes(error) && t('errors.default')}
)} @@ -177,6 +184,8 @@ function LoginContent() { } export default function LoginPage() { + const t = useTranslations('login'); + return (
@@ -184,9 +193,9 @@ export default function LoginPage() {
Wardrowbe
-

wardrowbe

+

{t('title')}

- Sign in to manage your wardrobe + {t('subtitle')}

@@ -195,7 +204,7 @@ export default function LoginPage() {

- By signing in, you agree to our terms of service and privacy policy. + {t('termsAgreement')}

diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx index e495f233..466e9933 100644 --- a/frontend/app/not-found.tsx +++ b/frontend/app/not-found.tsx @@ -1,20 +1,23 @@ import Link from 'next/link'; import { Home } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { getTranslations } from 'next-intl/server'; + +export default async function NotFound() { + const t = await getTranslations('notFound'); -export default function NotFound() { return (
-

404

-

Page not found

+

{t('title')}

+

{t('heading')}

- The page you're looking for doesn't exist or has been moved. + {t('description')}

diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx index 4b52282c..7b45881e 100644 --- a/frontend/app/onboarding/page.tsx +++ b/frontend/app/onboarding/page.tsx @@ -34,17 +34,21 @@ import { useUpdatePreferences } from '@/lib/hooks/use-preferences'; import { useCreateItem } from '@/lib/hooks/use-items'; import { useAuth } from '@/lib/hooks/use-auth'; import { api, setAccessToken } from '@/lib/api'; -import { CLOTHING_COLORS, CLOTHING_TYPES, StyleProfile } from '@/lib/types'; - -const STEPS = [ - { id: 'welcome', title: 'Welcome', icon: Shirt }, - { id: 'family', title: 'Family', icon: Users }, - { id: 'location', title: 'Location', icon: MapPin }, - { id: 'preferences', title: 'Style', icon: Palette }, - { id: 'upload', title: 'First Item', icon: Camera }, -]; +import { StyleProfile } from '@/lib/types'; +import { useClothingColors, useClothingTypes } from '@/lib/hooks/use-translated-constants'; +import { useTranslations } from 'next-intl'; function StepIndicator({ currentStep }: { currentStep: number }) { + const t = useTranslations('onboarding'); + + const STEPS = [ + { id: 'welcome', title: t('steps.welcome'), icon: Shirt }, + { id: 'family', title: t('steps.family'), icon: Users }, + { id: 'location', title: t('steps.location'), icon: MapPin }, + { id: 'preferences', title: t('steps.style'), icon: Palette }, + { id: 'upload', title: t('steps.firstItem'), icon: Camera }, + ]; + return (
{STEPS.map((step, index) => { @@ -80,8 +84,8 @@ function StepIndicator({ currentStep }: { currentStep: number }) { } function WelcomeStep({ onNext }: { onNext: () => void }) { - // Use unified auth hook to get user name (works in both auth modes) const { user } = useAuth(); + const t = useTranslations('onboarding'); return (
@@ -92,10 +96,10 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {

- Welcome to Wardrowbe{user?.display_name ? `, ${user.display_name.split(' ')[0]}` : ''}! + {t('welcome.greeting', { name: user?.display_name ? `, ${user.display_name.split(' ')[0]}` : '' })}

- Let's get your digital wardrobe set up in just a few steps. + {t('welcome.description')}

@@ -104,9 +108,9 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
-

Photograph your clothes

+

{t('welcome.feature1')}

- Our AI will automatically tag colors, styles, and more + {t('welcome.feature1Desc')}

@@ -115,9 +119,9 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
-

Get personalized outfits

+

{t('welcome.feature2')}

- Daily recommendations based on weather and your style + {t('welcome.feature2Desc')}

@@ -126,15 +130,15 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
-

Share with family

+

{t('welcome.feature3')}

- Everyone can have their own personalized wardrobe + {t('welcome.feature3Desc')}

@@ -145,6 +149,7 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void const [mode, setMode] = useState<'create' | 'join' | null>(null); const [familyName, setFamilyName] = useState(''); const [inviteCode, setInviteCode] = useState(''); + const t = useTranslations('onboarding'); const createFamily = useCreateFamily(); const joinFamily = useJoinFamily(); @@ -153,10 +158,10 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void if (!familyName.trim()) return; try { await createFamily.mutateAsync(familyName.trim()); - toast.success('Family created!'); + toast.success(t('family.success')); onNext(); } catch (error) { - toast.error('Failed to create family. Please try again.'); + toast.error(t('family.error')); } }; @@ -164,19 +169,19 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void if (!inviteCode.trim()) return; try { await joinFamily.mutateAsync(inviteCode.trim().toUpperCase()); - toast.success('Joined family!'); + toast.success(t('family.joinSuccess')); onNext(); } catch (error) { - toast.error('Invalid invite code. Please check and try again.'); + toast.error(t('family.error')); } }; return (
-

Family Setup

+

{t('family.title')}

- Create or join a family to share the wardrobe experience + {t('family.description')}

@@ -188,17 +193,17 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void onClick={() => setMode('create')} > - Create Family - Start a new family + {t('family.createFamily')} + {t('family.createFamilyDesc')} {mode === 'create' && (
- + setFamilyName(e.target.value)} /> @@ -209,7 +214,7 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void disabled={!familyName.trim() || createFamily.isPending} > {createFamily.isPending && } - Create Family + {t('family.createFamily')}
@@ -223,17 +228,17 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void onClick={() => setMode('join')} > - Join Family - Use an invite code + {t('family.joinFamily')} + {t('family.joinFamilyDesc')} {mode === 'join' && (
- + setInviteCode(e.target.value.toUpperCase())} className="font-mono uppercase" @@ -245,10 +250,10 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void disabled={!inviteCode.trim() || joinFamily.isPending} > {joinFamily.isPending && } - Join Family + {t('family.joinFamily')} {joinFamily.isError && ( -

Invalid invite code

+

{t('family.error')}

)}
@@ -258,7 +263,7 @@ function FamilyStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void
@@ -278,10 +283,11 @@ function LocationStep({ const [detecting, setDetecting] = useState(false); const [saving, setSaving] = useState(false); const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(null); + const t = useTranslations('onboarding'); const detectLocation = () => { if (!navigator.geolocation) { - toast.error('Geolocation is not supported by your browser'); + toast.error(t('location.locationError')); return; } @@ -315,7 +321,7 @@ function LocationStep({ }, (error) => { setDetecting(false); - toast.error('Could not detect location. Please enter manually.'); + toast.error(t('location.locationError')); } ); }; @@ -340,10 +346,10 @@ function LocationStep({ } await api.patch('/users/me', updateData); - toast.success('Location saved!'); + toast.success(t('location.locationSuccess')); onNext(); } catch (error) { - toast.error('Failed to save location. Please try again.'); + toast.error(t('location.saveError')); } finally { setSaving(false); } @@ -352,9 +358,9 @@ function LocationStep({ return (
-

Your Location

+

{t('location.title')}

- We use this to provide weather-appropriate outfit suggestions + {t('location.description')}

@@ -371,7 +377,7 @@ function LocationStep({ ) : ( )} - Detect My Location + {t('location.detectLocation')}
@@ -379,15 +385,15 @@ function LocationStep({
- Or enter manually + {t('location.orManual')}
- + setLocationName(e.target.value)} /> @@ -399,14 +405,14 @@ function LocationStep({ disabled={!locationName.trim() || saving} > {saving && } - Continue + {t('location.continue')}
@@ -425,6 +431,8 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => }); const [saving, setSaving] = useState(false); const updatePreferences = useUpdatePreferences(); + const t = useTranslations('onboarding'); + const clothingColors = useClothingColors(); const toggleColor = (color: string, list: 'favorite' | 'avoid') => { if (list === 'favorite') { @@ -454,10 +462,10 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => color_avoid: avoidColors, style_profile: styleProfile, }); - toast.success('Style preferences saved!'); + toast.success(t('style.saveSuccess')); onNext(); } catch (error) { - toast.error('Failed to save preferences. Please try again.'); + toast.error(t('style.saveError')); } finally { setSaving(false); } @@ -466,20 +474,20 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => return (
-

Your Style

+

{t('style.title')}

- Help us understand your style preferences + {t('style.description')}

- Favorite Colors - Tap colors you love wearing + {t('style.favoriteColors')} + {t('style.favoriteColorsDesc')}
- {CLOTHING_COLORS.map((color) => { + {clothingColors.map((color) => { const isSelected = favoriteColors.includes(color.value); return ( @@ -552,8 +560,8 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => - Style Profile - Adjust how much you prefer each style + {t('style.styleProfile')} + {t('style.styleProfileDesc')} {Object.entries(styleProfile).map(([key, value]) => ( @@ -578,11 +586,11 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () =>
@@ -594,8 +602,10 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void const [preview, setPreview] = useState(null); const [itemType, setItemType] = useState(''); const createItem = useCreateItem(); + const t = useTranslations('onboarding'); + const clothingTypes = useClothingTypes(); - // Clean up blob URL on unmount or when preview changes + // Clean up blob URL on unmount or when preview changes on unmount or when preview changes useEffect(() => { return () => { if (preview) { @@ -633,19 +643,19 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void try { await createItem.mutateAsync(formData); - toast.success('Item added to your wardrobe!'); + toast.success(t('firstItem.addToWardrobe')); onNext(); } catch (error) { - toast.error('Failed to upload item. Please try again.'); + toast.error(t('firstItem.uploadError')); } }; return (
-

Add Your First Item

+

{t('firstItem.title')}

- Take a photo or upload an image of a clothing item + {t('firstItem.description')}

@@ -657,7 +667,7 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void {/* eslint-disable-next-line @next/next/no-img-element */} Preview
@@ -666,15 +676,15 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void className="w-full" onClick={clearFile} > - Choose Different Photo + {t('firstItem.chooseDifferentPhoto')}
) : (
@@ -89,21 +91,21 @@ export function BulkActionToolbar({ {selectedCount === 0 ? ( - None selected + {t('noneSelected')} ) : selection.mode === 'all' && selection.excludedIds.size > 0 ? ( <> {totalItems - selection.excludedIds.size} - All except {selection.excludedIds.size} + {t('allExcept', { count: selection.excludedIds.size })} ) : selection.mode === 'all' ? ( <> All ({totalItems}) - All {totalItems} selected + {t('allSelected', { count: totalItems })} ) : ( <> {selectedCount} - {selectedCount} selected + {t('selected', { count: selectedCount })} )} @@ -147,13 +149,10 @@ export function BulkActionToolbar({ - Delete {selection.mode === 'all' && selection.excludedIds.size === 0 - ? `all ${totalItems}` - : selectedCount} items? + {t('deleteConfirm.title', { count: selection.mode === 'all' && selection.excludedIds.size === 0 ? totalItems : selectedCount })} - This will permanently delete the selected items and their images. - This action cannot be undone. + {t('deleteConfirm.description')} diff --git a/frontend/components/color-eyedropper.tsx b/frontend/components/color-eyedropper.tsx index 566e3ff7..31656c31 100644 --- a/frontend/components/color-eyedropper.tsx +++ b/frontend/components/color-eyedropper.tsx @@ -10,6 +10,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { CLOTHING_COLORS } from '@/lib/types'; +import { useClothingColors } from '@/lib/hooks/use-translated-constants'; +import { useTranslations } from 'next-intl'; interface ColorEyedropperProps { imageUrl: string; @@ -86,6 +88,8 @@ function findClosestColor(hex: string): ClothingColor { } export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedropperProps) { + const t = useTranslations('dialogs.colorEyedropper'); + const clothingColors = useClothingColors(); const [open, setOpen] = useState(false); const [pickedColor, setPickedColor] = useState(null); const [matchedColor, setMatchedColor] = useState(null); @@ -259,7 +263,7 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr variant="outline" size="icon" onClick={() => setOpen(true)} - title="Pick color from image" + title={t('buttonTitle')} > @@ -270,13 +274,13 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr - Pick Color from Image + {t('title')}

- Click anywhere on the image to sample a color + {t('description')}

@@ -328,7 +332,7 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr className="w-10 h-10 rounded border shadow-inner" style={{ backgroundColor: pickedColor }} /> - Picked + {t('picked')}
@@ -336,7 +340,7 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr className="w-10 h-10 rounded border shadow-inner" style={{ backgroundColor: matchedColor.hex }} /> - {matchedColor.name} + {clothingColors.find((c) => c.value === matchedColor.value)?.name ?? matchedColor.name}
@@ -349,11 +353,11 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr }} > - Clear + {t('clear')}
@@ -361,7 +365,7 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr {!pickedColor && (
- No color selected yet + {t('noColorSelected')}
)}
diff --git a/frontend/components/family-ratings.tsx b/frontend/components/family-ratings.tsx index 6c7ecc3c..dc336573 100644 --- a/frontend/components/family-ratings.tsx +++ b/frontend/components/family-ratings.tsx @@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { toast } from 'sonner'; import { useSubmitFamilyRating, useDeleteFamilyRating } from '@/lib/hooks/use-outfits'; import { FamilyRating } from '@/lib/types'; +import { useTranslations } from 'next-intl'; function StarPicker({ value, onChange }: { value: number; onChange: (v: number) => void }) { const [hovered, setHovered] = useState(0); @@ -42,13 +43,14 @@ interface FamilyRatingFormProps { } export function FamilyRatingForm({ outfitId, existingRating, onSuccess }: FamilyRatingFormProps) { + const t = useTranslations('cards.familyRatings'); const [rating, setRating] = useState(existingRating?.rating ?? 0); const [comment, setComment] = useState(existingRating?.comment ?? ''); const submitRating = useSubmitFamilyRating(); const handleSubmit = async () => { if (rating === 0) { - toast.error('Please select a rating'); + toast.error(t('selectRating')); return; } try { @@ -57,21 +59,21 @@ export function FamilyRatingForm({ outfitId, existingRating, onSuccess }: Family rating, comment: comment.trim() || undefined, }); - toast.success(existingRating ? 'Rating updated!' : 'Rating submitted!'); + toast.success(existingRating ? t('ratingUpdated') : t('ratingSubmitted')); onSuccess?.(); } catch { - toast.error('Failed to submit rating'); + toast.error(t('submitError')); } }; return (
- Your rating: + {t('yourRating')}