diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ec3c0be..656cfc09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,14 @@ jobs: cd frontend npx tsc --noEmit + # Missing translation keys and unwrapped strings are invisible to tsc, eslint and vitest, + # because t() takes a plain string. Without this step they only surface as raw key paths + # in front of a user. + - name: i18n check + run: | + cd frontend + npm run i18n:check + frontend-test: name: Frontend Tests runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b24faa97..43d05770 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,27 @@ repos: language: system files: ^frontend/.*\.(ts|tsx)$ pass_filenames: false + + # t() takes a plain string, so a missing translation key type-checks, lints and tests + # clean, then renders as a raw key path to the user. These three gates are the only + # thing that catches it. + - id: i18n-keys + name: i18n keys resolve + entry: bash -c 'cd frontend && node scripts/i18n-keys.mjs' + language: system + files: ^frontend/(app|components|lib|messages)/ + pass_filenames: false + + - id: i18n-parity + name: i18n locale parity + entry: bash -c 'cd frontend && node scripts/i18n-check.mjs' + language: system + files: ^frontend/messages/ + pass_filenames: false + + - id: i18n-scan + name: i18n no hardcoded strings + entry: bash -c 'cd frontend && node scripts/i18n-scan.mjs' + language: system + files: ^frontend/(app|components|lib)/.*\.tsx?$ + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25cb71c9..80a7bc8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,62 @@ export function ItemCard({ item, onSelect }: ItemCardProps) { } ``` +## Internationalization + +Wardrowbe ships in 8 languages. **Every user-visible string must go through `next-intl`.** A PR that +hardcodes English will be blocked by CI. + +Translation files live in `frontend/messages//.json`, split by feature area. +`en` is the source of truth; every other locale must have exactly the same key set. + +```typescript +import { useTranslations } from 'next-intl'; + +export function ItemCard({ item }: ItemCardProps) { + const t = useTranslations('wardrobe'); + const tc = useTranslations('common'); + + return ( +
+

{t('card.title')}

+

{t('card.wornCount', { count: item.wear_count })}

+ +
+ ); +} +``` + +Rules: + +- Add new keys to `frontend/messages/en/.json` only. Other locales are filled in by + translators; an untranslated key falls back to its English text rather than breaking the page. +- Reuse `common` for generic UI verbs (save, cancel, delete, loading) and `constants` for domain + vocabulary (clothing types, colors, occasions). Do not re-declare them in a feature namespace. +- Never build a sentence by concatenating fragments around a value. Use one ICU message: + `t('feelsLike', { temp })`, not `Feels {temp}`. +- Use ICU plurals for anything count-dependent: + `"itemCount": "{count, plural, one {{count} item} other {{count} items}}"`. +- `placeholder`, `title`, `aria-label`, `alt` and every `toast.*()` argument are user-visible too. + +Before pushing: + +```bash +cd frontend +npm run i18n:check +``` + +That runs three gates, none of which `tsc`, ESLint or Vitest can replace, because `t()` takes a +plain string and a missing key type-checks perfectly: + +| Gate | Catches | +|------|---------| +| `i18n:keys` | `t()` calls referencing keys absent from the `en` catalog | +| `i18n:parity` | locales missing keys, or ICU placeholders dropped in translation | +| `i18n:scan` | hardcoded user-visible strings in JSX, attributes and toasts | + +Adding a language: add it to `SUPPORTED_LOCALES` in `frontend/lib/i18n/locales.ts`, add the same +list to `backend/app/utils/locale.py`, and create `frontend/messages//`. + ## Project Structure ### Backend diff --git a/backend/app/api/users.py b/backend/app/api/users.py index bc02310d..198dec63 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -9,6 +9,7 @@ from app.models.user import User from app.services.user_service import UserService from app.utils.auth import get_current_user +from app.utils.locale import SUPPORTED_LOCALES, is_supported_locale router = APIRouter(prefix="/users/me", tags=["Users"]) @@ -23,6 +24,7 @@ class UserProfileResponse(BaseModel): display_name: str avatar_url: str | None = None timezone: str + locale: str location_lat: float | None = None location_lon: float | None = None location_name: str | None = None @@ -35,6 +37,7 @@ class UserProfileResponse(BaseModel): class UserProfileUpdate(BaseModel): display_name: str | None = None timezone: str | None = None + locale: str | None = None location_lat: Decimal | None = None location_lon: Decimal | None = None location_name: str | None = None @@ -56,6 +59,14 @@ async def update_profile( ) -> UserProfileResponse: update_data = data.model_dump(exclude_unset=True) + # update_data is applied with a blanket setattr below, so an unsupported locale + # must be rejected here to prevent it reaching the column. + if "locale" in update_data and not is_supported_locale(update_data["locale"]): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"locale must be one of: {', '.join(SUPPORTED_LOCALES)}", + ) + if "body_measurements" in update_data and update_data["body_measurements"] is not None: numeric_keys = {"chest", "waist", "hips", "inseam", "height", "weight"} for key, value in update_data["body_measurements"].items(): @@ -82,6 +93,7 @@ def _user_response(user: User) -> UserProfileResponse: display_name=user.display_name, avatar_url=user.avatar_url, timezone=user.timezone, + locale=user.locale, location_lat=float(user.location_lat) if user.location_lat else None, location_lon=float(user.location_lon) if user.location_lon else None, location_name=user.location_name, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f087b08f..c9c62cb1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base +from app.utils.locale import DEFAULT_LOCALE if TYPE_CHECKING: from app.models.family import Family @@ -36,6 +37,9 @@ class User(Base): avatar_url: Mapped[str | None] = mapped_column(String(500)) role: Mapped[str] = mapped_column(String(20), default="member") timezone: Mapped[str] = mapped_column(String(50), default="UTC") + locale: Mapped[str] = mapped_column( + String(10), default=DEFAULT_LOCALE, server_default=DEFAULT_LOCALE, nullable=False + ) # Location for weather location_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 8)) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8cd05487..3b217eb3 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -4,12 +4,15 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field +from app.utils.locale import DEFAULT_LOCALE + class UserBase(BaseModel): email: EmailStr display_name: str = Field(..., min_length=1, max_length=100) avatar_url: str | None = None timezone: str = Field(default="UTC", max_length=50) + locale: str = Field(default=DEFAULT_LOCALE, max_length=10) location_lat: Decimal | None = Field(None, ge=-90, le=90) location_lon: Decimal | None = Field(None, ge=-180, le=180) location_name: str | None = Field(None, max_length=100) @@ -23,6 +26,7 @@ class UserUpdate(BaseModel): display_name: str | None = Field(None, min_length=1, max_length=100) avatar_url: str | None = None timezone: str | None = Field(None, max_length=50) + locale: str | None = Field(None, max_length=10) location_lat: Decimal | None = Field(None, ge=-90, le=90) location_lon: Decimal | None = Field(None, ge=-180, le=180) location_name: str | None = Field(None, max_length=100) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index a30bc26a..a56d7a0b 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -39,6 +39,7 @@ async def create(self, user_data: UserCreate) -> User: display_name=user_data.display_name, avatar_url=user_data.avatar_url, timezone=user_data.timezone, + locale=user_data.locale, location_lat=user_data.location_lat, location_lon=user_data.location_lon, location_name=user_data.location_name, diff --git a/backend/app/utils/locale.py b/backend/app/utils/locale.py new file mode 100644 index 00000000..21cade65 --- /dev/null +++ b/backend/app/utils/locale.py @@ -0,0 +1,19 @@ +DEFAULT_LOCALE = "en" + +# Order is the order the UI language picker renders; keep it stable. +SUPPORTED_LOCALES: tuple[str, ...] = ( + "en", + "zh-CN", + "zh-TW", + "ko", + "ja", + "fr", + "de", + "it", +) + +_SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALES) + + +def is_supported_locale(value: object) -> bool: + return isinstance(value, str) and value in _SUPPORTED_LOCALE_SET diff --git a/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py b/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py new file mode 100644 index 00000000..b9c4d2f4 --- /dev/null +++ b/backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py @@ -0,0 +1,37 @@ +"""add locale to users + +Persists the UI language server-side so the choice follows a user across devices +instead of living only in a browser cookie. Existing rows are backfilled to 'en' +via the server_default. + +Revision ID: d4e5f6a7b8c9 +Revises: c1a2b3d4e5f6 +Create Date: 2026-07-29 10:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d4e5f6a7b8c9" +down_revision: str | None = "c1a2b3d4e5f6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "locale", + sa.String(length=10), + server_default="en", + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "locale") diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index 4ec85333..969a6770 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -61,6 +61,91 @@ async def test_update_user_location(self, client: AsyncClient, test_user, auth_h assert float(data["location_lon"]) == pytest.approx(-74.0060, rel=1e-4) +class TestUserLocale: + @pytest.mark.asyncio + async def test_default_locale_is_en(self, client: AsyncClient, test_user, auth_headers): + response = await client.get("/api/v1/users/me", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["locale"] == "en" + + @pytest.mark.asyncio + async def test_update_locale(self, client: AsyncClient, test_user, auth_headers): + response = await client.patch( + "/api/v1/users/me", + json={"locale": "zh-CN"}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["locale"] == "zh-CN" + + response = await client.get("/api/v1/users/me", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["locale"] == "zh-CN" + + @pytest.mark.asyncio + @pytest.mark.parametrize("locale", ["en", "zh-CN", "zh-TW", "ko", "ja", "fr", "de", "it"]) + async def test_all_supported_locales_accepted( + self, client: AsyncClient, test_user, auth_headers, locale + ): + response = await client.patch( + "/api/v1/users/me", + json={"locale": locale}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["locale"] == locale + + @pytest.mark.asyncio + @pytest.mark.parametrize("locale", ["xx", "", "en-US-posix", "x" * 11, "EN", "en_US", None]) + async def test_unsupported_locale_rejected( + self, client: AsyncClient, test_user, auth_headers, locale + ): + response = await client.patch( + "/api/v1/users/me", + json={"locale": locale}, + headers=auth_headers, + ) + assert response.status_code == 422 + + response = await client.get("/api/v1/users/me", headers=auth_headers) + assert response.json()["locale"] == "en" + + @pytest.mark.asyncio + async def test_update_locale_with_other_field( + self, client: AsyncClient, test_user, auth_headers + ): + response = await client.patch( + "/api/v1/users/me", + json={"locale": "ja", "display_name": "Locale User"}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["locale"] == "ja" + assert data["display_name"] == "Locale User" + + @pytest.mark.asyncio + async def test_omitting_locale_preserves_existing( + self, client: AsyncClient, test_user, auth_headers + ): + await client.patch("/api/v1/users/me", json={"locale": "de"}, headers=auth_headers) + + response = await client.patch( + "/api/v1/users/me", + json={"display_name": "Still German"}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["display_name"] == "Still German" + assert data["locale"] == "de" + + @pytest.mark.asyncio + async def test_update_locale_unauthorized(self, client: AsyncClient): + response = await client.patch("/api/v1/users/me", json={"locale": "fr"}) + assert response.status_code == 401 + + class TestOnboarding: """Tests for onboarding completion endpoint.""" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bc3f7d5d..d16a0f1e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -40,6 +40,7 @@ RUN chown nextjs:nodejs .next # Automatically leverage output traces to reduce image size COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/messages ./messages # Entrypoint drops to nextjs (remapped to PUID/PGID when set); when the # container is started as a non-root uid it execs directly diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx index 9047b321..4ea6f16f 100644 --- a/frontend/app/dashboard/analytics/page.tsx +++ b/frontend/app/dashboard/analytics/page.tsx @@ -16,6 +16,7 @@ import { Progress } from '@/components/ui/progress'; import { useAnalytics } from '@/lib/hooks/use-analytics'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; function StatCard({ title, @@ -95,6 +96,7 @@ function LoadingSkeleton() { } function ColorBar({ color, percentage }: { color: string; percentage: number }) { + const t = useTranslations('analytics'); const colorMap: Record = { black: 'bg-gray-900', white: 'bg-gray-100 border', @@ -128,7 +130,7 @@ function ColorBar({ color, percentage }: { color: string; percentage: number })
{color} - {percentage.toFixed(1)}% + {t('percent', { value: percentage.toFixed(1) })}
@@ -137,6 +139,7 @@ function ColorBar({ color, percentage }: { color: string; percentage: number }) } function ItemCard({ item }: { item: { id: string; name: string | null; type: string; thumbnail_url: string | null; wear_count: number } }) { + const t = useTranslations('analytics'); return ( {item.name || item.type}

{item.type}

- {item.wear_count}x + {t('wearCount', { count: item.wear_count })} ); } function AcceptanceTrendChart({ data }: { data: { period: string; rate: number; total: number }[] }) { + const t = useTranslations('analytics'); const maxTotal = Math.max(...data.map((d) => d.total), 1); return ( @@ -185,7 +189,7 @@ function AcceptanceTrendChart({ data }: { data: { period: string; rate: number; /> {week.total > 0 && ( - {week.rate.toFixed(0)}% + {t('percent', { value: week.rate.toFixed(0) })} )} @@ -195,14 +199,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 +217,7 @@ export default function AnalyticsPage() { if (isError || !data) { return (
- Failed to load analytics. Please try again. + {t('loadError')}
); } @@ -222,35 +227,35 @@ export default function AnalyticsPage() { return (
-

Analytics

-

Your wardrobe insights and statistics

+

{t('title')}

+

{t('subtitle')}

{/* Stats Cards */}
50 ? 'up' : undefined} />
@@ -261,7 +266,7 @@ export default function AnalyticsPage() { - Insights + {t('insights.title')} @@ -283,13 +288,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 +310,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 +340,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 +359,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 +378,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 +399,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/error.tsx b/frontend/app/dashboard/error.tsx index 79702d8d..51a21c50 100644 --- a/frontend/app/dashboard/error.tsx +++ b/frontend/app/dashboard/error.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { AlertTriangle, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { useTranslations } from 'next-intl'; export default function DashboardError({ error, @@ -12,6 +13,9 @@ export default function DashboardError({ error: Error & { digest?: string }; reset: () => void; }) { + const t = useTranslations('errors'); + const tc = useTranslations('common'); + useEffect(() => { console.error('Dashboard error:', error); }, [error]); @@ -25,15 +29,14 @@ export default function DashboardError({

- Failed to load this page + {t('pageLoad.title')}

- We encountered an error loading this content. This might be a - temporary issue. + {t('pageLoad.description')}

{process.env.NODE_ENV === 'development' && (
diff --git a/frontend/app/dashboard/family/feed/page.tsx b/frontend/app/dashboard/family/feed/page.tsx
index bcd42d96..c07ab069 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('family');
   const config: Record = {
     scheduled: {
       icon: Calendar,
-      label: 'Scheduled',
+      label: t('feed.sourceBadges.scheduled'),
       className: 'bg-primary/10 text-primary border-primary/20',
     },
     on_demand: {
       icon: Zap,
-      label: 'On Demand',
+      label: t('feed.sourceBadges.onDemand'),
       className: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
     },
     manual: {
       icon: Edit3,
-      label: 'Manual',
+      label: t('feed.sourceBadges.manual'),
       className: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
     },
     pairing: {
       icon: Zap,
-      label: 'Pairing',
+      label: t('feed.sourceBadges.pairing'),
       className: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
     },
   };
@@ -79,6 +81,8 @@ function FeedOutfitCard({
   memberName: string;
   onPreview: () => void;
 }) {
+  const t = useTranslations('family');
+  const tc = useTranslations('common');
   const [showRatingForm, setShowRatingForm] = useState(false);
   const myRating = outfit.family_ratings?.find((r) => r.user_id === currentMemberId);
 
@@ -98,7 +102,7 @@ function FeedOutfitCard({
               month: 'short',
               day: 'numeric',
               year: 'numeric',
-            }) : 'Lookbook'}
+            }) : t('feed.lookbook')}
           
         
@@ -152,7 +156,7 @@ function FeedOutfitCard({ ))}
- ({outfit.family_rating_count} rating{outfit.family_rating_count !== 1 ? 's' : ''}) + {t('feed.ratingCount', { count: outfit.family_rating_count })}
)} @@ -183,13 +187,13 @@ function FeedOutfitCard({ onClick={() => setShowRatingForm(true)} > - Rate {memberName}'s outfit + {t('feed.rateOutfit', { member: memberName })} ) ) : (
- Your rating: + {t('ratings.yourRating')}
{[1, 2, 3, 4, 5].map((star) => ( setShowRatingForm(!showRatingForm)} > - Edit + {tc('edit')}
)} @@ -235,12 +239,13 @@ function FeedOutfitCard({ } function NoFamilyState() { + const t = useTranslations('family'); return (
-

Family Feed

+

{t('feed.title')}

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

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

Join a family first

+

{t('feed.noFamily.title')}

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

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

Family Feed

+

{t('feed.title')}

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

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

No other members yet

+

{t('feed.noMembers.title')}

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

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

Family Feed

+

{t('feed.title')}

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

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

No outfits yet

+

{t('feed.noOutfits.title')}

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

) : ( @@ -407,7 +412,7 @@ function FeedContent() { key={outfit.id} outfit={outfit} currentMemberId={currentMember?.id} - memberName={selectedMemberInfo?.display_name.split(' ')[0] ?? 'their'} + memberName={selectedMemberInfo?.display_name.split(' ')[0] ?? t('feed.unknownMemberShort')} onPreview={() => setPreviewOutfit(outfit)} /> ))} diff --git a/frontend/app/dashboard/family/page.tsx b/frontend/app/dashboard/family/page.tsx index 1ce7f71a..1a5d5f40 100644 --- a/frontend/app/dashboard/family/page.tsx +++ b/frontend/app/dashboard/family/page.tsx @@ -56,8 +56,11 @@ import { useUpdateFamily, } from '@/lib/hooks/use-family'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; function NoFamilyView() { + const t = useTranslations('family'); + const tc = useTranslations('common'); const [mode, setMode] = useState<'create' | 'join' | null>(null); const [familyName, setFamilyName] = useState(''); const [inviteCode, setInviteCode] = useState(''); @@ -69,11 +72,11 @@ function NoFamilyView() { if (!familyName.trim()) return; try { await createFamily.mutateAsync(familyName.trim()); - toast.success('Family created!'); + toast.success(t('toasts.created')); setFamilyName(''); setMode(null); } catch (error) { - toast.error('Failed to create family. Please try again.'); + toast.error(t('toasts.createFailed')); } }; @@ -81,20 +84,20 @@ function NoFamilyView() { if (!inviteCode.trim()) return; try { await joinFamily.mutateAsync(inviteCode.trim().toUpperCase()); - toast.success('Joined family!'); + toast.success(t('toasts.joined')); 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('subtitle')}

@@ -103,18 +106,18 @@ function NoFamilyView() { - Create Family + {t('createFamily')} - Start a new family and invite members + {t('createFamilyDesc')} {mode === 'create' ? (
- + setFamilyName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreate()} @@ -126,16 +129,16 @@ function NoFamilyView() { disabled={!familyName.trim() || createFamily.isPending} > {createFamily.isPending && } - Create + {tc('create')}
) : ( )}
@@ -145,18 +148,18 @@ function NoFamilyView() { - Join Family + {t('joinFamily')} - Join an existing family with an invite code + {t('joinFamilyDesc')} {mode === 'join' ? (
- + setInviteCode(e.target.value.toUpperCase())} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} @@ -169,21 +172,21 @@ function NoFamilyView() { disabled={!inviteCode.trim() || joinFamily.isPending} > {joinFamily.isPending && } - Join + {tc('join')}
{joinFamily.isError && (

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

)}
) : ( )}
@@ -194,6 +197,8 @@ function NoFamilyView() { } function FamilyView() { + const t = useTranslations('family'); + const tc = useTranslations('common'); const { data: session } = useSession(); const { data: family, isLoading } = useFamily(); const [copied, setCopied] = useState(false); @@ -236,9 +241,9 @@ function FamilyView() { const handleRegenerateCode = async () => { try { await regenerateCode.mutateAsync(); - toast.success('New invite code generated!'); + toast.success(t('toasts.codeRegenerated')); } catch (error) { - toast.error('Failed to generate new code. Please try again.'); + toast.error(t('toasts.codeRegenerateFailed')); } }; @@ -246,10 +251,10 @@ function FamilyView() { if (!inviteEmail.trim()) return; try { await inviteMember.mutateAsync({ email: inviteEmail.trim(), role: inviteRole }); - toast.success('Invitation sent!'); + toast.success(t('toasts.inviteSent')); setInviteEmail(''); } catch (error) { - toast.error('Failed to send invite. Please try again.'); + toast.error(t('toasts.inviteFailed')); } }; @@ -257,11 +262,11 @@ function FamilyView() { if (!newName.trim()) return; try { await updateFamily.mutateAsync(newName.trim()); - toast.success('Family name updated!'); + toast.success(t('toasts.nameUpdated')); setEditingName(false); setNewName(''); } catch (error) { - toast.error('Failed to update name. Please try again.'); + toast.error(t('toasts.nameUpdateFailed')); } }; @@ -280,7 +285,7 @@ function FamilyView() {

{family.name}

- {family.members.length} member{family.members.length !== 1 ? 's' : ''} + {tc('memberCount', { count: family.members.length })}

@@ -292,27 +297,25 @@ function FamilyView() { setEditingName(true); }} > - Edit Name + {t('editName')} )} - Leave Family? + {t('leaveConfirm.title')} - {isAdmin && family.members.length > 1 - ? 'You are an admin. Make sure another member is an admin before leaving, or remove all other members first.' - : 'Are you sure you want to leave this family?'} + {t('leaveConfirm.description', { name: family.name })} - Cancel + {tc('cancel')} leaveFamily.mutate()} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" @@ -320,7 +323,7 @@ function FamilyView() { {leaveFamily.isPending ? ( ) : null} - Leave + {tc('leave')} @@ -336,15 +339,15 @@ function FamilyView() { setNewName(e.target.value)} - placeholder="Family name" + placeholder={t('familyNamePlaceholder')} onKeyDown={(e) => e.key === 'Enter' && handleUpdateName()} />
@@ -354,8 +357,8 @@ function FamilyView() { {/* Invite Code Card */} - Invite Code - Share this code with family members to let them join + {t('inviteCodeSection.title')} + {t('inviteCodeSection.description')}
@@ -387,14 +390,14 @@ function FamilyView() { {isAdmin && ( - Send Invite - Invite someone by email + {t('sendInvite')} + {t('sendInviteDesc')}
setInviteEmail(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleInvite()} @@ -407,14 +410,14 @@ function FamilyView() { - Member - Admin + {t('roles.member')} + {t('roles.admin')}
@@ -424,8 +427,8 @@ function FamilyView() { {/* Members List */} - Members - People in your family + {t('members.title')} + {t('members.description')}
@@ -444,13 +447,13 @@ function FamilyView() { {member.display_name} {member.email === currentEmail && ( - You + {tc('you')} )} {member.role === 'admin' && ( - Admin + {t('roles.admin')} )}
@@ -469,8 +472,8 @@ function FamilyView() { - Member - Admin + {t('roles.member')} + {t('roles.admin')} @@ -481,18 +484,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 + {tc('cancel')} removeMember.mutate(member.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - Remove + {tc('remove')} @@ -509,8 +512,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 +530,7 @@ function FamilyView() { {invite.email}
- Expires {new Date(invite.expires_at).toLocaleDateString()} + {t('pendingInvites.expires', { date: new Date(invite.expires_at).toLocaleDateString() })}
@@ -556,15 +559,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..2d3b7c6f 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,8 @@ function CalendarSkeleton() { } export default function HistoryPage() { + const t = useTranslations('history'); + const tc = useTranslations('constants'); const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); @@ -131,7 +133,7 @@ export default function HistoryPage() { if (isError) { return (
- Failed to load history. Please try again. + {t('loadError')}
); } @@ -141,9 +143,9 @@ export default function HistoryPage() { {/* Header */}
-

History

+

{t('title')}

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

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

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

)} @@ -214,9 +216,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..98af32f3 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; @@ -220,10 +222,10 @@ function ItemPairCard({ pair }: { pair: ItemPair }) {
- {successRate}% + {t('percent', { value: successRate })}
- {pair.times_paired}x paired + {t('timesPaired', { count: pair.times_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 + {t('confidence', { percent: Math.round(insight.confidence * 100) })}
@@ -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')}

) : (
@@ -509,7 +513,9 @@ export default function LearningPage() { className={`w-24 h-2 ${isPositive ? '' : '[&>div]:bg-red-500'}`} /> - {isPositive ? '+' : ''}{(styleScore.score * 100).toFixed(0)}% + {t('percent', { + value: `${isPositive ? '+' : ''}${(styleScore.score * 100).toFixed(0)}`, + })}
@@ -527,9 +533,9 @@ export default function LearningPage() { - Your Best Combinations + {t('bestCombinations.title')} - Item pairs that you consistently love together + {t('bestCombinations.description')}
@@ -547,9 +553,9 @@ export default function LearningPage() { - Occasion Patterns + {t('occasionPatterns.title')} - What works for different occasions + {t('occasionPatterns.description')}
@@ -558,12 +564,12 @@ export default function LearningPage() {

{pattern.occasion}

- {Math.round(pattern.success_rate * 100)}% success + {t('successRate', { 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 +610,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('successRate', { percent: Math.round(pref.success_rate * 100) })}
))} @@ -622,17 +628,17 @@ export default function LearningPage() { - Suggested Preference Updates + {t('suggestedUpdates.title')} - Based on your feedback, we suggest updating your preferences + {t('suggestedUpdates.description')}
{preference_suggestions.suggestions.suggested_favorite_colors && (
- Add to favorite colors: + {t('suggestedUpdates.addToFavorites')}
{preference_suggestions.suggestions.suggested_favorite_colors.map((color) => ( @@ -645,7 +651,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 +666,7 @@ export default function LearningPage() {
@@ -671,7 +677,7 @@ export default function LearningPage() { {/* Last Updated */} {profile.last_computed_at && (

- Learning profile last updated: {new Date(profile.last_computed_at).toLocaleString()} + {t('lastUpdated', { date: 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..22c82a31 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 +288,7 @@ function AddChannelDialog({ {channel === 'ntfy' && ( <>
- +
- +

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

- + setConfig({ ...config, token: e.target.value })} - placeholder="tk_..." + placeholder={t('channels.placeholders.accessToken')} />

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

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

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

)} {channel === 'email' && (
- + setConfig({ ...config, address: e.target.value })} - placeholder="you@example.com" + placeholder={t('channels.placeholders.emailAddress')} required />
@@ -348,16 +357,16 @@ function AddChannelDialog({
@@ -378,12 +387,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,9 +406,12 @@ function ScheduleCard({
-

{day?.label}

+

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

- {schedule.notification_time} - {occasion?.label || schedule.occasion} + {t('schedule.summary', { + time: schedule.notification_time, + occasion: occasion?.label || schedule.occasion, + })}

@@ -417,12 +431,12 @@ function ScheduleCard({ onCheckedChange={onToggleDayBefore} />
{schedule.notify_day_before && ( - {notifyDay?.label} evening + {t('schedule.notifyDayEvening', { day: notifyDay ? t(`days.${notifyDay.key}`) : '' })} )}
@@ -445,6 +459,9 @@ function AddScheduleDialog({ onAdd: (data: ScheduleFormData) => Promise; isLoading: boolean; }) { + const t = useTranslations('notifications'); + const tc = useTranslations('common'); + const occasions = useOccasions(); const [open, setOpen] = useState(false); const [time, setTime] = useState('07:00'); const [occasion, setOccasion] = useState('casual'); @@ -453,8 +470,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 +505,20 @@ function AddScheduleDialog({ - Add Schedule + {t('schedule.addSchedule')} - Set up when you want to receive outfit recommendations. + {t('schedule.dialogDescription')}
- +
- +
- + setSearch(e.target.value)} className="pl-9 h-9" @@ -481,7 +484,7 @@ function OutfitsPageContent() { {listQuery.data && ( - {listQuery.data.total} total + {t('totalCount', { count: listQuery.data.total })} )}
@@ -490,7 +493,7 @@ function OutfitsPageContent() { {view === 'list' ? ( <> {listError ? ( -
Failed to load outfits
+
{t('loadError')}
) : listLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( @@ -499,12 +502,12 @@ function OutfitsPageContent() {
) : outfits.length === 0 ? (
-

{EMPTY_MESSAGES[chip]}

+

{t(EMPTY_KEYS[chip])}

{chip === 'my-looks' && ( )} @@ -530,7 +533,7 @@ function OutfitsPageContent() { {hasMore && (
)} @@ -571,7 +574,7 @@ function OutfitsPageContent() {
{calendarError ? ( -
Failed to load outfits
+
{t('loadError')}
) : calendarLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( @@ -582,7 +585,7 @@ function OutfitsPageContent() {

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

) : ( @@ -593,13 +596,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 })}

)}
@@ -623,7 +626,7 @@ function OutfitsPageContent() { onClear={handleClearSelection} onDelete={handleBulkDelete} isDeleting={bulkDeleteOutfits.isPending} - itemLabel="outfits" + variant="outfits" page={page} pageSize={24} onPageChange={setPage} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index cac513e4..2e0b0c9f 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,24 @@ function PendingOutfitsCard() { const { data, isLoading } = usePendingOutfits(2); const acceptOutfit = useAcceptOutfit(); const rejectOutfit = useRejectOutfit(); + const t = useTranslations('dashboard'); + const tc = useTranslations('common'); 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 dismissed'); + toast.success(t('pendingOutfits.dismissed')); } catch { - toast.error('Failed to dismiss outfit'); + toast.error(t('pendingOutfits.dismissFailed')); } }; @@ -144,7 +148,7 @@ function PendingOutfitsCard() { - Pending Outfits + {t('pendingOutfits.title')} @@ -163,12 +167,12 @@ function PendingOutfitsCard() { - All Caught Up + {t('pendingOutfits.allCaughtUp')}

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

@@ -181,12 +185,12 @@ function PendingOutfitsCard() {
- Pending Outfits + {t('pendingOutfits.title')} {data?.total || pendingOutfits.length} {(data?.total ?? 0) > 2 && ( - View all + {tc('viewAll')} )}
@@ -223,7 +227,7 @@ function PendingOutfitsCard() { weekday: 'short', month: 'short', day: 'numeric', - }) : 'Lookbook'} + }) : t('pendingOutfits.lookbook')}

@@ -233,7 +237,7 @@ function PendingOutfitsCard() { className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleReject(outfit.id)} disabled={rejectOutfit.isPending} - aria-label="Dismiss outfit" + aria-label={t('pendingOutfits.dismissLabel')} > @@ -256,6 +260,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; @@ -289,15 +295,13 @@ function NextScheduledCard() { return closest; }, [schedules]); - const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - if (isLoading) { return ( - Next Scheduled + {t('nextScheduled.title')} @@ -314,13 +318,13 @@ function NextScheduledCard() { - Next Scheduled + {t('nextScheduled.title')} -

No schedules set up

+

{t('nextScheduled.noSchedules')}

@@ -329,25 +333,30 @@ 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')}

- {dayStr} at {timeStr} + {t('nextScheduled.dayAtTime', { day: dayStr, time: timeStr })}

- {schedule.occasion} outfit + {t('nextScheduled.occasionOutfit', { occasion: schedule.occasion })}

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

No channels configured

+

{t('notifications.noChannels')}

@@ -402,10 +412,10 @@ function NotificationStatusCard() {
- Notifications + {t('notifications.title')} - Configure + {t('notifications.configure')}
@@ -427,7 +437,7 @@ function NotificationStatusCard() { ))}

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

@@ -436,6 +446,7 @@ function NotificationStatusCard() { function WeeklySummaryCard() { const { data: analytics, isLoading } = useAnalytics(); + const t = useTranslations('dashboard'); if (isLoading) { return ( @@ -443,7 +454,7 @@ function WeeklySummaryCard() { - This Week + {t('weeklySummary.title')} @@ -465,25 +476,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.avgRatingValue', { rating: wardrobe.average_rating })}

)}
@@ -493,6 +504,8 @@ function WeeklySummaryCard() { function InsightsCard() { const { data: analytics, isLoading } = useAnalytics(); + const t = useTranslations('dashboard'); + const tc = useTranslations('common'); if (isLoading) { return ( @@ -500,7 +513,7 @@ function InsightsCard() { - Insights + {t('insights.title')} @@ -521,11 +534,11 @@ function InsightsCard() {
- Insights + {t('insights.title')} {insights.length > 3 && ( - View all + {tc('viewAll')} )}
@@ -542,7 +555,7 @@ function InsightsCard() { ) : (

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

)}
@@ -552,6 +565,7 @@ function InsightsCard() { function FamilyFeedCard() { const { data: family, isLoading } = useFamily(); + const t = useTranslations('dashboard'); if (isLoading) return null; @@ -565,20 +579,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 })}
@@ -588,23 +602,25 @@ function FamilyFeedCard() { } function QuickActionsCard() { + const t = useTranslations('dashboard'); + return ( - Quick Actions - Common tasks to get you started + {t('quickActions.title')} + {t('quickActions.description')} @@ -614,15 +630,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] || t('userFallback') })}

- 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 f3944575..2c16f93a 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,20 +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')}

- Open an item in your Wardrobe (once it has finished analyzing) and use its - “Find matching outfits” button to discover outfit combinations that work - well together. + {t('empty.description')}

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

- Pairings + {t('title')}

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

@@ -109,20 +110,20 @@ export default function PairingsPage() {
{data && (

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

)}
@@ -131,7 +132,7 @@ export default function PairingsPage() { {isLoading ? ( ) : !data || data.pairings.length === 0 ? ( - + ) : ( <>
@@ -152,7 +153,7 @@ export default function PairingsPage() { variant="outline" onClick={() => setPage((p) => p + 1)} > - Load More + {tc('loadMore')}
)} diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index c173964d..3110d857 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -22,13 +22,14 @@ import { useUserProfile, useUpdateUserProfile } from '@/lib/hooks/use-user'; import { getNetworkLocationUrl, formatReverseGeocodedLocation, - getGeolocationFailureMessage, isNetworkLocationFallbackEnabled, resolveNetworkLocation, } from '@/lib/location'; -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; @@ -53,13 +54,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; @@ -82,6 +76,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)); @@ -94,7 +90,7 @@ function ColorPicker({
- {CLOTHING_COLORS.map((color) => { + {clothingColors.map((color) => { const isSelected = selected.includes(color.value); return (
@@ -556,17 +571,17 @@ export default function SettingsPage() { {/* Account Section */} - Account - Your profile information + {t('account.title')} + {t('account.description')}
- +
- +
@@ -578,24 +593,24 @@ export default function SettingsPage() { - Location + {t('location.title')} - Set your location for weather-based outfit recommendations + {t('location.description')}
- + setLocationName(e.target.value)} - placeholder="e.g., London, UK" + placeholder={t('location.cityPlaceholder')} />
- +
- +
- +
@@ -651,7 +666,7 @@ export default function SettingsPage() { ) : ( )} - Use My Location + {t('location.useMyLocation')}
{!locationLat && !locationLon && (

- Location is required for weather-based outfit recommendations. + {t('location.required')}

)}
@@ -678,27 +693,27 @@ export default function SettingsPage() { - Body Measurements + {t('body.title')} - Help AI recommend better-fitting outfits + {t('body.description')}
- +
- +
{BODY_MEASUREMENT_FIELDS.map((field) => { const unit = unitSystem === 'metric' ? field.unitMetric : field.unitImperial; const placeholder = unitSystem === 'metric' ? field.placeholderMetric : field.placeholderImperial; return (
- +
- +
- {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}`)} />
))} @@ -740,9 +760,9 @@ export default function SettingsPage() { size="sm" > {updateUserProfile.isPending ? ( - <>Saving... + <>{tc('saving')} ) : ( - <>Save Measurements + <>{t('body.saveMeasurements')} )} )} @@ -752,19 +772,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)} /> @@ -774,34 +794,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)} /> @@ -811,15 +831,15 @@ export default function SettingsPage() { {/* Temperature & Comfort */} - Temperature & Comfort + {t('temperature.title')} - Adjust how recommendations adapt to weather + {t('temperature.description')}
- +
- +
- +
@@ -883,7 +903,7 @@ export default function SettingsPage() { return ( <>
- +
- + - Recommendation Settings + {t('recommendations.title')} - Customize how outfit recommendations are generated + {t('recommendations.description')}
- +
- +
- +
- +
@@ -997,16 +1017,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')}

) : (
@@ -1083,16 +1103,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')} )}
@@ -1113,17 +1133,17 @@ export default function SettingsPage() { {endpointTests[index]?.status === 'connected' && endpointTests[index]?.models && (

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

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

- Vision: {endpointTests[index].visionModels?.slice(0, 3).join(', ')} + {t('aiEndpoints.visionModels', { models: 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.textModels', { models: endpointTests[index].textModels?.slice(0, 3).join(', ') ?? '' })} {(endpointTests[index].textModels?.length || 0) > 3 && '...'}

)} @@ -1136,7 +1156,7 @@ export default function SettingsPage() { )}
- + { @@ -1144,12 +1164,12 @@ export default function SettingsPage() { updated[index] = { ...updated[index], name: e.target.value }; updateField('ai_endpoints', updated); }} - placeholder="e.g., Local Ollama" + placeholder={t('aiEndpoints.placeholders.name')} className="h-8" />
- + { @@ -1162,7 +1182,7 @@ export default function SettingsPage() { />
- + { @@ -1175,7 +1195,7 @@ export default function SettingsPage() { />
- + { @@ -1208,7 +1228,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 14c98514..3b62a07e 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,12 +43,21 @@ 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'; import { TempUnit, formatTemp, displayValue, toF, toCelsius } from '@/lib/temperature'; +type Translator = (key: string, values?: Record) => string; + +const OVERRIDE_CONDITION_KEYS: Record = { + sunny: 'clear', + cloudy: 'cloudy', + rainy: 'rain', +}; + // Map occasion values to icons and colors const OCCASION_CONFIG: Record = { casual: { icon: , color: 'hover:border-blue-400 hover:bg-blue-50 data-[selected=true]:border-blue-500 data-[selected=true]:bg-blue-50 data-[selected=true]:text-blue-700' }, @@ -69,25 +79,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 +105,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: Translator }) { if (isLoading) { return ( @@ -121,9 +131,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')}

@@ -151,21 +161,21 @@ function WeatherCard({ weather, isLoading, temperatureUnit }: { weather?: Weathe
- Feels {displayValue(weather.feels_like, temperatureUnit)}° + {t('weather.feelsLike', { temp: displayValue(weather.feels_like, temperatureUnit) })}
- {weather.precipitation_chance}% rain + {t('weather.rainChance', { chance: weather.precipitation_chance })}
- {Math.round(weather.wind_speed)} km/h + {t('weather.windSpeed', { speed: Math.round(weather.wind_speed) })}

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

@@ -180,9 +190,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 ( @@ -237,10 +251,10 @@ function WeatherOverrideSection({
- Condition + {t('weatherOverride.condition')} {weather && ( )}
@@ -262,13 +276,13 @@ function WeatherOverrideSection({ )} > {c.icon} - {c.label} + {tc(OVERRIDE_CONDITION_KEYS[c.value])} ))}
{weather && (
- Temperature + {t('weatherOverride.temperature')} void; onTryAnother: () => void; onNewRequest: () => void; + t: Translator; }) { return (
@@ -322,7 +338,7 @@ function OutfitResult({ )}
@@ -332,11 +348,11 @@ function OutfitResult({
{formatTemp(outfit.weather.temperature, temperatureUnit)} - (feels {displayValue(outfit.weather.feels_like, temperatureUnit)}°) + {t('weather.feelsLikeInline', { temp: displayValue(outfit.weather.feels_like, temperatureUnit) })}
- {outfit.weather.precipitation_chance}% rain + {t('weather.rainChance', { chance: outfit.weather.precipitation_chance })}
{outfit.weather.condition} @@ -349,7 +365,7 @@ function OutfitResult({
-

Your Outfit

+

{t('yourOutfit')}

{outfit.reasoning && (

{outfit.reasoning}

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

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

)} @@ -416,13 +432,13 @@ function OutfitResult({
-
@@ -431,6 +447,7 @@ function OutfitResult({ } export default function SuggestPage() { + const t = useTranslations('suggest'); const { data: session } = useSession(); const { data: weather, isLoading: weatherLoading } = useWeather(); const { data: prefs } = usePreferences(); @@ -480,7 +497,7 @@ export default function SuggestPage() { if (err instanceof ApiError) { setError(err.message); } else { - setError('Failed to generate outfit suggestion. Please try again.'); + setError(t('error')); } console.error('Suggestion error:', err); } finally { @@ -536,9 +553,9 @@ export default function SuggestPage() {
{/* Page header with greeting */}
-

{getGreeting()}

+

{t(getGreetingKey())}

- Let's find the perfect outfit for your day + {t('subtitle')}

@@ -552,14 +569,14 @@ export default function SuggestPage() { {!outfit ? (
{/* Weather context */} - + {/* Main selection card */} {/* Occasion selection */}
-

What's the occasion?

+

{t('occasionPrompt')}

{/* Generate button */} @@ -584,12 +602,12 @@ export default function SuggestPage() { {isGenerating ? ( <> - Creating your look... + {t('generating')} ) : ( <> - Get Suggestion + {t('getSuggestion')} )} @@ -606,6 +624,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 b24390e5..4550a4ba 100644 --- a/frontend/app/dashboard/wardrobe/page.tsx +++ b/frontend/app/dashboard/wardrobe/page.tsx @@ -28,21 +28,28 @@ import { ItemDetailDialog } from '@/components/item-detail-dialog'; import { BulkActionToolbar, BulkSelection } from '@/components/bulk-action-toolbar'; import { useItems, useItem, useItemTypes, useReanalyzeItem, useCancelAnalysis, 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 PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; 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({ @@ -66,7 +73,10 @@ function ItemCard({ errorDismissed?: boolean; userTimezone: string; }) { - const colorInfo = CLOTHING_COLORS.find((c) => c.value === item.primary_color); + const t = useTranslations('wardrobe'); + const tc = useTranslations('common'); + const clothingColors = useClothingColors(); + const colorInfo = clothingColors.find((c) => c.value === item.primary_color); const isProcessing = item.status === 'processing'; const isError = item.status === 'error' && !errorDismissed; @@ -115,7 +125,7 @@ function ItemCard({ )} {item.needs_wash && (
-
+
@@ -123,7 +133,7 @@ function ItemCard({ {isProcessing && (
- AI Analyzing... + {t('ai.analyzing')} {onCancelAnalysis && ( )}
@@ -143,7 +153,7 @@ function ItemCard({ {isError && (
- Analysis Failed + {t('ai.analysisFailed')}
{onRetry && ( )} {onDismissError && ( @@ -164,7 +174,7 @@ function ItemCard({ size="sm" variant="secondary" className="h-7 w-7 p-0" - title="Dismiss" + title={t('ai.dismiss')} onClick={(e) => { e.stopPropagation(); onDismissError(item.id); @@ -186,7 +196,7 @@ function ItemCard({

{item.type} {item.subtype && ` • ${item.subtype}`} - {item.tags?.logprobs_confidence != null && ` · ${Math.round(item.tags.logprobs_confidence * 100)}% confident`} + {item.tags?.logprobs_confidence != null && ` · ${t('ai.confident', { percent: Math.round(item.tags.logprobs_confidence * 100) })}`}

{colorInfo && ( @@ -207,16 +217,16 @@ function ItemCard({
{item.last_worn_at ? (

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

) : 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) })}

)} @@ -237,19 +247,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')}

); @@ -260,6 +271,9 @@ export default function WardrobePage() { const router = useRouter(); const { data: userProfile } = useUserProfile(); const userTimezone = userProfile?.timezone || 'UTC'; + const t = useTranslations('wardrobe'); + const tc = useTranslations('common'); + const clothingTypes = useClothingTypes(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [selection, setSelection] = useState({ mode: 'none', @@ -463,13 +477,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')); } }; @@ -478,16 +492,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')); } }; @@ -500,26 +514,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 })} )}
@@ -527,7 +541,7 @@ export default function WardrobePage() {
@@ -537,7 +551,7 @@ export default function WardrobePage() {
{ setSearch(e.target.value); @@ -561,7 +575,7 @@ export default function WardrobePage() { {SORT_OPTIONS.map((opt, i) => ( - {opt.label} + {t(`sort.${SORT_LABEL_KEYS[i]}`)} ))} @@ -593,13 +607,13 @@ export default function WardrobePage() { }} > - + - All types - {CLOTHING_TYPES.map((t) => ( - - {t.label} + {t('allTypes')} + {clothingTypes.map((type) => ( + + {type.label} ))} @@ -618,7 +632,7 @@ export default function WardrobePage() { {PAGE_SIZE_OPTIONS.map((size) => ( - {size} per page + {t('pageSize', { count: size })} ))} @@ -634,7 +648,7 @@ export default function WardrobePage() { }} > - Needs wash + {t('needsWash')} {activeFilterCount > 0 && ( @@ -663,7 +677,7 @@ export default function WardrobePage() { }} > - Clear filters + {t('clearFilters')} )}
@@ -673,14 +687,14 @@ export default function WardrobePage() { {error ? (

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

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

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

) : ( @@ -748,8 +762,7 @@ export default function WardrobePage() { onReanalyze={handleBulkReanalyze} isDeleting={bulkDelete.isPending} isReanalyzing={bulkReanalyze.isPending} - itemLabel="items" - deleteWarningSuffix=" and their images" + variant="items" page={page} pageSize={pageSize} onPageChange={handlePageChange} diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx index 0b886bba..e7c0f8de 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('common'); + useEffect(() => { console.error('Application error:', error); }, [error]); @@ -21,16 +24,15 @@ export default function GlobalError({
-

Something went wrong

+

{t('somethingWentWrong')}

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

- +
{process.env.NODE_ENV === 'development' && (
diff --git a/frontend/app/invite/page.tsx b/frontend/app/invite/page.tsx
index 9e26ed82..a42e3ebd 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('invite.invalidLink');
+    if (error.status === 403) return t('invite.wrongEmail');
+    if (error.status === 409) return t('invite.alreadyInFamily');
   }
-  return 'Something went wrong. Please try again.';
+  return t('default');
 }
 
 function InviteContent() {
@@ -26,6 +27,8 @@ function InviteContent() {
   const { status } = useSession();
   const token = searchParams.get('token');
   const joinByToken = useJoinFamilyByToken();
+  const t = useTranslations('auth');
+  const te = useTranslations('errors');
 
   useEffect(() => {
     if (!token) {
@@ -50,7 +53,7 @@ function InviteContent() {
   const handleAccept = async () => {
     try {
       const result = await joinByToken.mutateAsync(token);
-      toast.success(`Joined ${result.family_name}!`);
+      toast.success(t('invite.joinedFamily', { name: result.family_name }));
       router.push('/dashboard/family');
     } catch {
       // error displayed via joinByToken.error below
@@ -63,15 +66,15 @@ function InviteContent() {
         
           
             
-            Family Invitation
+            {t('invite.title')}
           
           
-            You've been invited to join a family on Wardrowbe
+            {t('invite.description')}
           
         
         
           {joinByToken.isError && (
-            

{getErrorMessage(joinByToken.error)}

+

{getErrorMessage(joinByToken.error, te)}

)}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2ad3b777..71d8988a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,11 +1,14 @@ 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'; +import { LOCALE_METADATA, type SupportedLocale } from '@/lib/i18n/locales'; export const dynamic = 'force-dynamic'; -const inter = Inter({ subsets: ['latin'] }); +const inter = Inter({ subsets: ['latin', 'latin-ext'], variable: '--font-inter', display: 'swap' }); export const metadata: Metadata = { title: 'Wardrowbe', @@ -31,15 +34,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 3cba3eae..8ae25bb6 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('auth'); + 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('auth'); 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')}
setName(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - placeholder="Your Name" + placeholder={t('namePlaceholder')} />
@@ -85,9 +89,11 @@ function DevLogin({ callbackUrl }: { callbackUrl: string }) { } function BackendError({ message }: { message: string }) { + const t = useTranslations('auth'); + return (
-

Backend Configuration Error

+

{t('backendError.title')}

{message}

); @@ -101,6 +107,7 @@ function LoginContent() { const syncErrorParam = searchParams.get('syncError'); const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; const [backendError, setBackendError] = useState(null); + const t = useTranslations('auth'); useEffect(() => { if (status === 'authenticated' && session?.accessToken) { @@ -118,9 +125,9 @@ function LoginContent() { } }) .catch(() => { - setBackendError('Unable to connect to backend server. Please check that the backend is running.'); + setBackendError(t('backendError.description')); }); - }, []); + }, [t]); const syncError = syncErrorParam || session?.syncError; @@ -154,14 +161,14 @@ 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'} - {error === 'undefined' && 'No authentication provider is configured. Set OIDC_ISSUER_URL or enable DEV_MODE.'} - {!['OAuthSignin', 'OAuthCallback', 'OAuthCreateAccount', 'Callback', 'CredentialsSignin', 'AccessDenied', 'undefined'].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')} + {error === 'undefined' && t('errors.notConfigured')} + {!['OAuthSignin', 'OAuthCallback', 'OAuthCreateAccount', 'Callback', 'CredentialsSignin', 'AccessDenied', 'undefined'].includes(error) && t('errors.default')}
)} @@ -170,11 +177,11 @@ function LoginContent() { {authMode === 'dev' && } {authMode === 'unconfigured' && (
-

No authentication method configured

+

{t('unconfigured.title')}

- Set OIDC_ISSUER_URL +{' '} - OIDC_CLIENT_ID for SSO, or add{' '} - DEV_MODE=true to the frontend service for local use. + {t.rich('unconfigured.description', { + code: (chunks) => {chunks}, + })}

)} @@ -184,6 +191,8 @@ function LoginContent() { } export default function LoginPage() { + const t = useTranslations('auth'); + return (
@@ -191,9 +200,9 @@ export default function LoginPage() {
Wardrowbe
-

wardrowbe

+

{t('title')}

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

@@ -202,7 +211,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..ed528ab9 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('common'); -export default function NotFound() { return (
-

404

-

Page not found

+

{t('notFoundCode')}

+

{t('notFound')}

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

diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx index 4b52282c..b8bca44e 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,12 @@ 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 tc = useTranslations('common'); const detectLocation = () => { if (!navigator.geolocation) { - toast.error('Geolocation is not supported by your browser'); + toast.error(t('location.locationError')); return; } @@ -315,7 +322,7 @@ function LocationStep({ }, (error) => { setDetecting(false); - toast.error('Could not detect location. Please enter manually.'); + toast.error(t('location.locationError')); } ); }; @@ -340,10 +347,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 +359,9 @@ function LocationStep({ return (
-

Your Location

+

{t('location.title')}

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

@@ -371,7 +378,7 @@ function LocationStep({ ) : ( )} - Detect My Location + {t('location.detectLocation')}
@@ -379,15 +386,15 @@ function LocationStep({
- Or enter manually + {t('location.orManual')}
- + setLocationName(e.target.value)} /> @@ -399,14 +406,14 @@ function LocationStep({ disabled={!locationName.trim() || saving} > {saving && } - Continue + {tc('continue')}
@@ -425,6 +432,10 @@ function PreferencesStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => }); const [saving, setSaving] = useState(false); const updatePreferences = useUpdatePreferences(); + const t = useTranslations('onboarding'); + const tc = useTranslations('common'); + const tStyles = useTranslations('constants.styles'); + const clothingColors = useClothingColors(); const toggleColor = (color: string, list: 'favorite' | 'avoid') => { if (list === 'favorite') { @@ -454,10 +465,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 +477,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,15 +563,17 @@ 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]) => (
- - {value}% + + + {t('style.percentValue', { value })} +
void; onSkip: () =>
@@ -594,8 +607,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 +648,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.uploadSuccess')); 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 +672,7 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void {/* eslint-disable-next-line @next/next/no-img-element */} Preview
@@ -666,15 +681,15 @@ function UploadStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void className="w-full" onClick={clearFile} > - Choose Different Photo + {t('firstItem.chooseDifferentPhoto')}
) : (
@@ -409,8 +414,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {

- {bulkFiles.length} image{bulkFiles.length !== 1 ? 's' : ''} selected - + {t('bulk.imageCount', { count: bulkFiles.length })}

@@ -455,12 +459,12 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { onCheckedChange={(checked) => setSkipAi(checked === true)} />
{!skipAi && (

- All items will be auto-tagged by AI. You can edit details later. + {t('bulk.hint')}

)}
@@ -471,7 +475,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {
- Uploading {bulkFiles.length} items... + {t('bulk.uploadingCount', { count: bulkFiles.length })}
{bulkCreateItems.uploadProgress}%
@@ -481,7 +485,7 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {
@@ -516,11 +520,11 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {

- {bulkResult.successful} of {bulkResult.total} uploaded successfully + {t('bulk.resultSuccess', { success: bulkResult.successful, total: bulkResult.total })}

{bulkResult.failed > 0 && (

- {bulkResult.failed} item{bulkResult.failed !== 1 ? 's' : ''} failed + {t('bulk.resultFailed', { count: bulkResult.failed })}

)}
@@ -555,10 +559,10 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) {
@@ -571,14 +575,16 @@ export function AddItemDialog({ open, onOpenChange }: AddItemDialogProps) { - Discard selected images? + {t('bulk.discardConfirm.title')} - You have {activeTab === 'single' ? '1 image' : `${bulkFiles.length} image${bulkFiles.length !== 1 ? 's' : ''}`} selected that will be lost if you close this dialog. + {activeTab === 'single' + ? t('bulk.discardConfirm.singleImage') + : t('bulk.discardConfirm.imageCount', { count: bulkFiles.length })} - Keep editing - Discard + {t('bulk.discardConfirm.keepEditing')} + {t('bulk.discardConfirm.discard')} diff --git a/frontend/components/bulk-action-toolbar.tsx b/frontend/components/bulk-action-toolbar.tsx index f15432c8..40a1d144 100644 --- a/frontend/components/bulk-action-toolbar.tsx +++ b/frontend/components/bulk-action-toolbar.tsx @@ -13,6 +13,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; +import { useTranslations } from 'next-intl'; export interface BulkSelection { mode: 'none' | 'some' | 'all'; @@ -20,6 +21,8 @@ export interface BulkSelection { excludedIds: Set; // Used when mode is 'all' } +export type BulkDeleteVariant = 'items' | 'outfits'; + interface BulkActionToolbarProps { selection: BulkSelection; totalItems: number; @@ -31,8 +34,7 @@ interface BulkActionToolbarProps { onReanalyze?: () => void; isDeleting?: boolean; isReanalyzing?: boolean; - itemLabel?: string; - deleteWarningSuffix?: string; + variant?: BulkDeleteVariant; // Pagination props page: number; pageSize: number; @@ -50,13 +52,18 @@ export function BulkActionToolbar({ onReanalyze, isDeleting = false, isReanalyzing = false, - itemLabel = 'items', - deleteWarningSuffix = '', + variant = 'items', page, pageSize, onPageChange, }: BulkActionToolbarProps) { - // Calculate selected count + const t = useTranslations('common'); + const tWardrobe = useTranslations('wardrobe'); + const tOutfits = useTranslations('outfits'); + // Each variant carries a whole delete sentence so that gendered and case-inflected + // languages are never handed a bare noun to splice into English grammar. + const tDelete = variant === 'outfits' ? tOutfits : tWardrobe; + const selectedCount = selection.mode === 'all' ? totalItems - selection.excludedIds.size : selection.selectedIds.size; @@ -91,7 +98,7 @@ export function BulkActionToolbar({ )} - {isAllSelected ? 'All' : 'Select all'} + {isAllSelected ? t('bulkActions.all') : t('selectAll')}
@@ -99,21 +106,21 @@ export function BulkActionToolbar({ {selectedCount === 0 ? ( - None selected + {t('bulkActions.noneSelected')} ) : selection.mode === 'all' && selection.excludedIds.size > 0 ? ( <> {totalItems - selection.excludedIds.size} - All except {selection.excludedIds.size} + {t('bulkActions.allExcept', { count: selection.excludedIds.size })} ) : selection.mode === 'all' ? ( <> - All ({totalItems}) - All {totalItems} selected + {t('bulkActions.allShort', { count: totalItems })} + {t('bulkActions.allSelected', { count: totalItems })} ) : ( <> {selectedCount} - {selectedCount} selected + {t('selected', { count: selectedCount })} )} @@ -125,7 +132,7 @@ export function BulkActionToolbar({ className="h-8 px-0 text-xs shrink-0 hidden sm:inline-flex" onClick={onSelectAllMatching} > - Select all {totalItems} matching + {t('bulkActions.selectAllMatching', { count: totalItems })} )} @@ -136,7 +143,7 @@ export function BulkActionToolbar({ size="icon" onClick={onClear} className="text-muted-foreground h-8 w-8 shrink-0" - aria-label="Clear selection" + aria-label={t('bulkActions.clearSelection')} > @@ -148,7 +155,7 @@ export function BulkActionToolbar({ className="h-8 w-8 shrink-0" onClick={onReanalyze} disabled={isReanalyzing} - aria-label="Re-analyze" + aria-label={t('bulkActions.reanalyze')} > {isReanalyzing ? ( @@ -159,7 +166,7 @@ export function BulkActionToolbar({ )} - @@ -214,7 +220,7 @@ export function BulkActionToolbar({ className="h-8 w-8" disabled={page === 1} onClick={() => onPageChange(page - 1)} - aria-label="Previous page" + aria-label={t('previousPage')} > @@ -227,7 +233,7 @@ export function BulkActionToolbar({ className="h-8 w-8" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} - aria-label="Next page" + aria-label={t('nextPage')} > @@ -237,7 +243,7 @@ export function BulkActionToolbar({ className="h-8 w-8 hidden sm:flex" disabled={page >= totalPages} onClick={() => onPageChange(totalPages)} - aria-label="Last page" + aria-label={t('lastPage')} > diff --git a/frontend/components/color-eyedropper.tsx b/frontend/components/color-eyedropper.tsx index 566e3ff7..79bfa28f 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,9 @@ function findClosestColor(hex: string): ClothingColor { } export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedropperProps) { + const t = useTranslations('wardrobe.colorEyedropper'); + const tc = useTranslations('common'); + const clothingColors = useClothingColors(); const [open, setOpen] = useState(false); const [pickedColor, setPickedColor] = useState(null); const [matchedColor, setMatchedColor] = useState(null); @@ -126,14 +131,14 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr const timer = setTimeout(() => { const canvas = canvasRef.current; if (!canvas) { - setError('Canvas not available'); + setError(t('errors.canvasUnavailable')); setIsLoading(false); return; } const ctx = canvas.getContext('2d'); if (!ctx) { - setError('Could not get canvas context'); + setError(t('errors.canvasContext')); setIsLoading(false); return; } @@ -141,7 +146,7 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr // Fetch image as blob to avoid CORS issues with canvas fetch(imageUrl, { credentials: 'include' }) .then(response => { - if (!response.ok) throw new Error(`Failed to load image: ${response.status}`); + if (!response.ok) throw new Error(t('errors.imageLoadStatus', { status: response.status })); return response.blob(); }) .then(blob => { @@ -178,19 +183,19 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr setImageLoaded(true); }; img.onerror = () => { - setError('Failed to load image from blob'); + setError(t('errors.imageDecodeFailed')); setIsLoading(false); }; img.src = blobUrl; }) .catch(err => { - setError(err.message || 'Failed to load image'); + setError(err.message || t('errors.imageLoadFailed')); setIsLoading(false); }); }, 100); // Small delay to ensure DOM is ready return () => clearTimeout(timer); - }, [open, imageUrl, imageLoaded]); + }, [open, imageUrl, imageLoaded, t]); const getColorAtPosition = useCallback((x: number, y: number): string | null => { const canvas = canvasRef.current; @@ -259,7 +264,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 +275,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 +333,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 +341,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 +354,11 @@ export function ColorEyedropper({ imageUrl, onColorSelect, trigger }: ColorEyedr }} > - Clear + {tc('clear')}
@@ -361,7 +366,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..a26acd60 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('family.ratings'); 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')}