From c40e1eb04dd7b14bcc62b867985d6854bb71454c Mon Sep 17 00:00:00 2001 From: apple Date: Wed, 13 May 2026 02:41:18 +0100 Subject: [PATCH 1/7] feat: add weather location fallbacks with geocoding support Add geocoding service to resolve location names to coordinates using Nominatim API. Add frontend location utilities for network-based location detection and reverse geocoding. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/weather.py | 19 +++-- backend/app/services/weather_service.py | 38 +++++++++ backend/tests/test_weather_api.py | 67 +++++++++++++++ frontend/lib/location.ts | 108 ++++++++++++++++++++++++ frontend/tests/location.test.ts | 81 ++++++++++++++++++ 5 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_weather_api.py create mode 100644 frontend/lib/location.ts create mode 100644 frontend/tests/location.test.ts diff --git a/backend/app/api/weather.py b/backend/app/api/weather.py index 665c68ae..c1092f1c 100644 --- a/backend/app/api/weather.py +++ b/backend/app/api/weather.py @@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field - from app.models.user import User from app.services.weather_service import WeatherService, WeatherServiceError from app.utils.auth import get_current_user @@ -54,18 +53,21 @@ async def get_current_weather( latitude: float | None = Query(None, ge=-90, le=90), longitude: float | None = Query(None, ge=-180, le=180), ) -> WeatherResponse: - # Use provided coordinates or fall back to user's location + weather_service = WeatherService() lat = latitude if latitude is not None else current_user.location_lat lon = longitude if longitude is not None else current_user.location_lon + if (lat is None or lon is None) and current_user.location_name: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + if geocoded: + lat, lon, _ = geocoded + if lat is None or lon is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Location not set. Please provide coordinates or set your location in settings.", ) - weather_service = WeatherService() - try: weather = await weather_service.get_current_weather(float(lat), float(lon)) except WeatherServiceError as e: @@ -97,18 +99,21 @@ async def get_weather_forecast( longitude: float | None = Query(None, ge=-180, le=180), days: int = Query(7, ge=1, le=16), ) -> ForecastResponse: - # Use provided coordinates or fall back to user's location + weather_service = WeatherService() lat = latitude if latitude is not None else current_user.location_lat lon = longitude if longitude is not None else current_user.location_lon + if (lat is None or lon is None) and current_user.location_name: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + if geocoded: + lat, lon, _ = geocoded + if lat is None or lon is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Location not set. Please provide coordinates or set your location in settings.", ) - weather_service = WeatherService() - try: forecast = await weather_service.get_daily_forecast(float(lat), float(lon), days) except WeatherServiceError as e: diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 9ba227f9..bca43550 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -98,6 +98,44 @@ class WeatherService: def __init__(self): self.base_url = settings.openmeteo_url + async def geocode_location_name(self, location_name: str) -> tuple[float, float, str | None] | None: + """Resolve a free-form location name to coordinates using Nominatim.""" + query = location_name.strip() + if not query: + return None + + params = { + "q": query, + "format": "jsonv2", + "limit": 1, + } + + headers = { + "User-Agent": "Wardrowbe/1.0 (local-docker-geocoding)", + } + + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client: + try: + response = await client.get("https://nominatim.openstreetmap.org/search", params=params) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as e: + logger.error(f"Geocoding error for {query!r}: {e}") + return None + + if not data: + return None + + first = data[0] + try: + latitude = float(first["lat"]) + longitude = float(first["lon"]) + except (KeyError, TypeError, ValueError): + return None + + display_name = first.get("display_name") + return latitude, longitude, display_name + @staticmethod def _cache_key(lat: float, lon: float) -> str: return f"{CACHE_PREFIX}{round(lat, 2)},{round(lon, 2)}" diff --git a/backend/tests/test_weather_api.py b/backend/tests/test_weather_api.py new file mode 100644 index 00000000..5ce72a85 --- /dev/null +++ b/backend/tests/test_weather_api.py @@ -0,0 +1,67 @@ +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + + +class TestWeatherApi: + @pytest.mark.asyncio + async def test_current_weather_uses_location_name_without_persisting_coordinates( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(return_value=(40.7128, -74.0060, "New York City")) + weather_mock = AsyncMock( + return_value=type( + "Weather", + (), + { + "temperature": 12.5, + "feels_like": 7.3, + "humidity": 50, + "precipitation_chance": 10, + "precipitation_mm": 0.0, + "wind_speed": 23.4, + "condition": "partly cloudy", + "condition_code": 2, + "is_day": True, + "uv_index": 1.8, + "timestamp": datetime(2026, 5, 12, 15, 21, 35), + }, + )() + ) + + with ( + patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock), + patch("app.api.weather.WeatherService.get_current_weather", weather_mock), + ): + response = await client.get("/api/v1/weather/current", headers=auth_headers) + + assert response.status_code == 200 + geocode_mock.assert_awaited_once_with("New York City") + weather_mock.assert_awaited_once_with(40.7128, -74.0060) + + await db_session.refresh(test_user) + assert test_user.location_lat is None + assert test_user.location_lon is None + + @pytest.mark.asyncio + async def test_forecast_returns_400_when_location_missing( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = None + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + response = await client.get("/api/v1/weather/forecast", headers=auth_headers) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "Location not set. Please provide coordinates or set your location in settings." + ) diff --git a/frontend/lib/location.ts b/frontend/lib/location.ts new file mode 100644 index 00000000..3c60d3a6 --- /dev/null +++ b/frontend/lib/location.ts @@ -0,0 +1,108 @@ +export interface NetworkLocationApiResponse { + success?: boolean; + latitude?: number; + longitude?: number; + city?: string; + region?: string; + country?: string; + country_name?: string; + timezone?: { + id?: string; + } | string; + error?: boolean; + reason?: string; + message?: string; +} + +export interface ResolvedLocation { + lat: string; + lon: string; + locationName?: string; + timezone?: string; +} + +export interface ReverseGeocodeResponse { + address?: { + city?: string; + town?: string; + village?: string; + municipality?: string; + country?: string; + }; + display_name?: string; +} + +export function resolveNetworkLocation( + data: NetworkLocationApiResponse, + fallbackTimezone?: string +): ResolvedLocation { + if ( + data.success === false || + data.error === true || + typeof data.latitude !== 'number' || + typeof data.longitude !== 'number' + ) { + throw new Error(data.reason || data.message || 'Unable to determine location from network'); + } + + const city = typeof data.city === 'string' ? data.city : ''; + const region = typeof data.region === 'string' ? data.region : ''; + const country = + typeof data.country === 'string' + ? data.country + : typeof data.country_name === 'string' + ? data.country_name + : ''; + const labelParts = [city, region, country].filter(Boolean); + const timezoneId = + typeof data.timezone === 'string' + ? data.timezone + : data.timezone?.id; + + return { + lat: data.latitude.toFixed(6), + lon: data.longitude.toFixed(6), + locationName: labelParts.length > 0 ? labelParts.slice(0, 2).join(', ') : undefined, + timezone: + typeof timezoneId === 'string' && timezoneId + ? timezoneId + : fallbackTimezone, + }; +} + +export function formatReverseGeocodedLocation( + data: ReverseGeocodeResponse +): string | undefined { + const city = + data.address?.city || + data.address?.town || + data.address?.village || + data.address?.municipality; + const country = data.address?.country; + + if (city && country) return `${city}, ${country}`; + if (city) return city; + if (data.display_name) { + return data.display_name.split(',').slice(0, 2).join(',').trim(); + } + return undefined; +} + +export function getGeolocationFailureMessage(error: { + code?: number; + message?: string; +}): string { + const reasons: Record = { + 1: 'Location access was denied.', + 2: 'Location is currently unavailable.', + 3: 'Location request timed out.', + }; + + if (typeof error.code === 'number' && reasons[error.code]) { + return reasons[error.code]; + } + if (error.message) { + return `Failed to get exact location: ${error.message}`; + } + return 'Failed to get exact location.'; +} diff --git a/frontend/tests/location.test.ts b/frontend/tests/location.test.ts new file mode 100644 index 00000000..2c251808 --- /dev/null +++ b/frontend/tests/location.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { + formatReverseGeocodedLocation, + getGeolocationFailureMessage, + resolveNetworkLocation, +} from '@/lib/location' + +describe('location helpers', () => { + it('resolves network location with formatted city label and timezone', () => { + const result = resolveNetworkLocation({ + success: true, + latitude: 40.7128, + longitude: -74.006, + city: 'New York', + region: 'New York', + country: 'United States', + timezone: { id: 'America/New_York' }, + }, 'UTC') + + expect(result).toEqual({ + lat: '40.712800', + lon: '-74.006000', + locationName: 'New York, New York', + timezone: 'America/New_York', + }) + }) + + it('falls back to provided timezone when network response omits one', () => { + const result = resolveNetworkLocation({ + success: true, + latitude: 40.7128, + longitude: -74.006, + }, 'America/New_York') + + expect(result.timezone).toBe('America/New_York') + }) + + it('supports alternate provider response shapes', () => { + const result = resolveNetworkLocation({ + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + region: 'California', + country_name: 'United States', + timezone: 'America/Los_Angeles', + }, 'UTC') + + expect(result).toEqual({ + lat: '37.774900', + lon: '-122.419400', + locationName: 'San Francisco, California', + timezone: 'America/Los_Angeles', + }) + }) + + it('throws when network location is incomplete', () => { + expect(() => resolveNetworkLocation({ success: false }, 'UTC')).toThrow( + 'Unable to determine location from network' + ) + }) + + it('formats reverse geocoding responses consistently', () => { + expect(formatReverseGeocodedLocation({ + address: { city: 'London', country: 'United Kingdom' }, + })).toBe('London, United Kingdom') + + expect(formatReverseGeocodedLocation({ + display_name: 'Paris, Ile-de-France, France', + })).toBe('Paris, Ile-de-France') + }) + + it('maps geolocation failure reasons to user-facing messages', () => { + expect(getGeolocationFailureMessage({ code: 1 })).toBe('Location access was denied.') + expect(getGeolocationFailureMessage({ code: 2 })).toBe('Location is currently unavailable.') + expect(getGeolocationFailureMessage({ code: 3 })).toBe('Location request timed out.') + expect(getGeolocationFailureMessage({ message: 'Permission prompt dismissed' })).toBe( + 'Failed to get exact location: Permission prompt dismissed' + ) + }) +}) From 505ecb352c45f4f92c40a86f7b53465cafc74f9d Mon Sep 17 00:00:00 2001 From: Gianni-Zhang <120421065+gzhang33@users.noreply.github.com> Date: Tue, 12 May 2026 18:42:40 +0100 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/app/services/weather_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index bca43550..d3b6c59c 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -119,7 +119,7 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, response = await client.get("https://nominatim.openstreetmap.org/search", params=params) response.raise_for_status() data = response.json() - except httpx.HTTPError as e: + except (httpx.HTTPError, ValueError) as e: logger.error(f"Geocoding error for {query!r}: {e}") return None From ac117575da6b9c93432f764ad16d755ad6ae94c2 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 19:30:46 +0100 Subject: [PATCH 3/7] fix: handle geocoding failures explicitly --- backend/app/api/weather.py | 24 +++++++++++++-- backend/app/services/weather_service.py | 12 +++++++- backend/tests/test_weather_api.py | 39 +++++++++++++++++++++++++ backend/tests/test_weather_service.py | 14 +++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/backend/app/api/weather.py b/backend/app/api/weather.py index c1092f1c..66e09253 100644 --- a/backend/app/api/weather.py +++ b/backend/app/api/weather.py @@ -4,13 +4,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from app.models.user import User -from app.services.weather_service import WeatherService, WeatherServiceError +from app.services.weather_service import GeocodingServiceError, WeatherService, WeatherServiceError from app.utils.auth import get_current_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/weather", tags=["Weather"]) +GEOCODING_FAILURE_DETAIL = ( + "Unable to geocode saved location name. Please try again later or update your location in settings." +) + class WeatherResponse(BaseModel): temperature: float = Field(description="Temperature in Celsius") @@ -58,7 +62,14 @@ async def get_current_weather( lon = longitude if longitude is not None else current_user.location_lon if (lat is None or lon is None) and current_user.location_name: - geocoded = await weather_service.geocode_location_name(current_user.location_name) + try: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + except GeocodingServiceError as e: + logger.error(f"Geocoding service error: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=GEOCODING_FAILURE_DETAIL, + ) from None if geocoded: lat, lon, _ = geocoded @@ -104,7 +115,14 @@ async def get_weather_forecast( lon = longitude if longitude is not None else current_user.location_lon if (lat is None or lon is None) and current_user.location_name: - geocoded = await weather_service.geocode_location_name(current_user.location_name) + try: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + except GeocodingServiceError as e: + logger.error(f"Geocoding service error: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=GEOCODING_FAILURE_DETAIL, + ) from None if geocoded: lat, lon, _ = geocoded diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index d3b6c59c..3e0588dc 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass from datetime import datetime +from json import JSONDecodeError import httpx import redis.asyncio as aioredis @@ -121,7 +122,12 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, data = response.json() except (httpx.HTTPError, ValueError) as e: logger.error(f"Geocoding error for {query!r}: {e}") - return None + raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None + except (JSONDecodeError, ValueError) as e: + logger.error(f"Geocoding returned invalid JSON for {query!r}: {e}") + raise GeocodingServiceError( + f"Failed to decode geocoding response for location {query!r}: {e}" + ) from None if not data: return None @@ -387,3 +393,7 @@ async def check_health(self) -> dict: class WeatherServiceError(Exception): pass + + +class GeocodingServiceError(WeatherServiceError): + pass diff --git a/backend/tests/test_weather_api.py b/backend/tests/test_weather_api.py index 5ce72a85..3d6f4778 100644 --- a/backend/tests/test_weather_api.py +++ b/backend/tests/test_weather_api.py @@ -4,6 +4,9 @@ import pytest from httpx import AsyncClient +from app.api.weather import GEOCODING_FAILURE_DETAIL +from app.services.weather_service import GeocodingServiceError + class TestWeatherApi: @pytest.mark.asyncio @@ -65,3 +68,39 @@ async def test_forecast_returns_400_when_location_missing( assert response.json()["detail"] == ( "Location not set. Please provide coordinates or set your location in settings." ) + + @pytest.mark.asyncio + async def test_current_weather_returns_503_when_saved_location_geocoding_fails( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable")) + + with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock): + response = await client.get("/api/v1/weather/current", headers=auth_headers) + + assert response.status_code == 503 + assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL + geocode_mock.assert_awaited_once_with("New York City") + + @pytest.mark.asyncio + async def test_forecast_returns_503_when_saved_location_geocoding_fails( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable")) + + with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock): + response = await client.get("/api/v1/weather/forecast", headers=auth_headers) + + assert response.status_code == 503 + assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL + geocode_mock.assert_awaited_once_with("New York City") diff --git a/backend/tests/test_weather_service.py b/backend/tests/test_weather_service.py index fc1804ed..40dd7fea 100644 --- a/backend/tests/test_weather_service.py +++ b/backend/tests/test_weather_service.py @@ -8,6 +8,7 @@ from app.services.weather_service import ( CACHE_PREFIX, + GeocodingServiceError, WMO_CODES, WeatherData, WeatherService, @@ -237,6 +238,19 @@ async def test_handles_missing_hourly_precipitation(self, weather_service, mock_ assert result.precipitation_chance == 0 +class TestGeocodeLocationName: + @pytest.mark.asyncio + async def test_raises_on_invalid_json_response(self, weather_service): + request = httpx.Request("GET", "https://nominatim.openstreetmap.org/search") + mock_response = httpx.Response(200, text="rate limited", request=request) + + with patch("httpx.AsyncClient.get", return_value=mock_response): + with pytest.raises( + GeocodingServiceError, match="Failed to decode geocoding response" + ): + await weather_service.geocode_location_name("New York City") + + class TestGetDailyForecast: @pytest.mark.asyncio async def test_returns_forecast_days(self, weather_service, mock_redis): From dd2ba7e98ec8fc74fbceac3d7b3d3bfbedfd2929 Mon Sep 17 00:00:00 2001 From: apple Date: Wed, 13 May 2026 02:44:02 +0100 Subject: [PATCH 4/7] refactor: centralize geocoding and location provider config Move geocoding user-agent configuration to Settings class. Add environment variable overrides for geocoding and network location provider. Update frontend location utilities with configurable URL. Co-Authored-By: Claude Opus 4.6 --- .env.example | 8 ++ backend/app/config.py | 12 +++ backend/app/main.py | 2 +- backend/app/services/weather_service.py | 2 +- backend/tests/test_config.py | 23 +++++ frontend/app/dashboard/settings/page.tsx | 107 +++++++++++++++++------ frontend/lib/location.ts | 6 ++ frontend/tests/location.test.ts | 28 ++++++ 8 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 backend/tests/test_config.py diff --git a/.env.example b/.env.example index ad962404..73505317 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,12 @@ SECRET_KEY=change-me-in-production # Uncomment to enable dev credential login (no OIDC required). Requires the # SECRET_KEY above to remain "change-me-in-production". # DEBUG=true +# Weather/geocoding integrations +# Optional override for the Nominatim User-Agent header. If unset, Wardrowbe +# sends "${APP_NAME}/${APP_VERSION}" and appends GEOCODING_CONTACT when present. +# GEOCODING_USER_AGENT=Wardrowbe/1.0.0 (https://example.com/contact) +# Optional contact URL or email to append to the default geocoding User-Agent. +# GEOCODING_CONTACT=https://example.com/contact # Frontend Configuration # ============================================================================ @@ -33,6 +39,8 @@ FRONTEND_PORT=3000 NEXTAUTH_URL=http://localhost:3000 # IMPORTANT: Generate a secure secret for production NEXTAUTH_SECRET=change-me-in-production-use-openssl-rand-hex-32 +# Optional override for browser-side approximate location provider. +# NEXT_PUBLIC_NETWORK_LOCATION_URL=https://ipapi.co/json/ # Authentication (OIDC Provider) # ============================================================================ diff --git a/backend/app/config.py b/backend/app/config.py index e8846819..410b2233 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,7 @@ class Settings(BaseSettings): # Application app_name: str = "Wardrowbe" + app_version: str = "1.0.0" debug: bool = False secret_key: str = Field(default=DEFAULT_SECRET_KEY) studio_disabled: bool = False @@ -52,6 +53,8 @@ class Settings(BaseSettings): # Weather openmeteo_url: str = Field(default="https://api.open-meteo.com/v1") + geocoding_user_agent: str | None = Field(default=None) + geocoding_contact: str | None = Field(default=None) # Notifications - default ntfy channel (used when user has none configured) ntfy_server: str | None = None @@ -110,6 +113,15 @@ def get_auth_mode(self) -> str: return "oidc" return "unknown" + def get_geocoding_user_agent(self) -> str: + if self.geocoding_user_agent: + return self.geocoding_user_agent + + base = f"{self.app_name}/{self.app_version}" + if self.geocoding_contact: + return f"{base} ({self.geocoding_contact})" + return base + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/main.py b/backend/app/main.py index 9a4fce3d..a1a44363 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,7 +30,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title=settings.app_name, description="AI-powered wardrobe management and outfit recommendations", - version="1.0.0", + version=settings.app_version, lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url="/redoc" if settings.debug else None, diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 3e0588dc..738e65a6 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -112,7 +112,7 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, } headers = { - "User-Agent": "Wardrowbe/1.0 (local-docker-geocoding)", + "User-Agent": settings.get_geocoding_user_agent(), } async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client: diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 00000000..ecbfb1e0 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,23 @@ +from app.config import Settings + + +class TestGeocodingUserAgent: + def test_uses_explicit_user_agent_when_configured(self): + settings = Settings( + geocoding_user_agent="WardrowbeCustom/2.0 (+https://example.com/contact)" + ) + + assert settings.get_geocoding_user_agent() == ( + "WardrowbeCustom/2.0 (+https://example.com/contact)" + ) + + def test_builds_user_agent_from_app_metadata_and_contact(self): + settings = Settings( + app_name="Wardrowbe", + app_version="1.2.3", + geocoding_contact="https://example.com/contact", + ) + + assert settings.get_geocoding_user_agent() == ( + "Wardrowbe/1.2.3 (https://example.com/contact)" + ) diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index b5797346..9b3bb1b5 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -19,6 +19,12 @@ import { import { Badge } from '@/components/ui/badge'; import { usePreferences, useUpdatePreferences, useResetPreferences, useTestAIEndpoint } from '@/lib/hooks/use-preferences'; import { useUserProfile, useUpdateUserProfile } from '@/lib/hooks/use-user'; +import { + getNetworkLocationUrl, + formatReverseGeocodedLocation, + getGeolocationFailureMessage, + resolveNetworkLocation, +} from '@/lib/location'; import { CLOTHING_COLORS, OCCASIONS, Preferences, StyleProfile, AIEndpoint } from '@/lib/types'; import { toF, toCelsius } from '@/lib/temperature'; import { toast } from 'sonner'; @@ -216,49 +222,94 @@ export default function SettingsPage() { } }, [userProfile]); + const detectLocationFromNetwork = async () => { + const response = await fetch(getNetworkLocationUrl(), { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error( + `Network-based location lookup failed (${response.status}${response.statusText ? ` ${response.statusText}` : ''})` + ); + } + + const data = await response.json(); + const resolved = resolveNetworkLocation(data, timezone); + setLocationLat(resolved.lat); + setLocationLon(resolved.lon); + if (resolved.locationName) { + setLocationName(resolved.locationName); + } + if (resolved.timezone) { + setTimezone(resolved.timezone); + } + return resolved; + }; + const handleGetCurrentLocation = () => { + setIsGettingLocation(true); + const finalizeFromCoordinates = async (lat: string, lon: string) => { + // Reverse geocode to get city name + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json` + ); + if (response.ok) { + const data = await response.json(); + const nextLocationName = formatReverseGeocodedLocation(data); + if (nextLocationName) { + setLocationName(nextLocationName); + return nextLocationName; + } + } + } catch { + // Ignore geocoding errors, we still have coordinates + } + + return undefined; + }; + + const fallbackToNetworkLocation = async (reason?: string) => { + try { + await detectLocationFromNetwork(); + toast.success( + reason + ? `${reason} Approximate location filled in. Review it, then save.` + : 'Approximate location filled in. Review it, then save.' + ); + } catch (fallbackError) { + const fallbackMessage = fallbackError instanceof Error + ? fallbackError.message + : 'Unable to detect your location'; + toast.error(fallbackMessage); + } finally { + setIsGettingLocation(false); + } + }; + if (!navigator.geolocation) { - toast.error('Geolocation is not supported by your browser'); + void fallbackToNetworkLocation('Geolocation is not supported by your browser.'); return; } - setIsGettingLocation(true); navigator.geolocation.getCurrentPosition( async (position) => { const lat = position.coords.latitude.toFixed(6); const lon = position.coords.longitude.toFixed(6); setLocationLat(lat); setLocationLon(lon); - - // Reverse geocode to get city name - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`, - { headers: { 'User-Agent': 'WardrobeAI/1.0' } } - ); - if (response.ok) { - const data = await response.json(); - const city = data.address?.city || data.address?.town || data.address?.village || data.address?.municipality; - const country = data.address?.country; - if (city && country) { - setLocationName(`${city}, ${country}`); - } else if (city) { - setLocationName(city); - } else if (data.display_name) { - // Fallback to first part of display name - setLocationName(data.display_name.split(',').slice(0, 2).join(',').trim()); - } - } - } catch { - // Ignore geocoding errors, we still have coordinates + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (detectedTimezone) { + setTimezone(detectedTimezone); } - + await finalizeFromCoordinates(lat, lon); setIsGettingLocation(false); - toast.success('Location detected'); + toast.success('Location detected. Review it, then save.'); }, (error) => { - setIsGettingLocation(false); - toast.error(`Failed to get location: ${error.message}`); + void fallbackToNetworkLocation( + getGeolocationFailureMessage(error) + ); }, { enableHighAccuracy: true, timeout: 10000 } ); diff --git a/frontend/lib/location.ts b/frontend/lib/location.ts index 3c60d3a6..a3e283bb 100644 --- a/frontend/lib/location.ts +++ b/frontend/lib/location.ts @@ -32,6 +32,12 @@ export interface ReverseGeocodeResponse { display_name?: string; } +export const DEFAULT_NETWORK_LOCATION_URL = 'https://ipapi.co/json/'; + +export function getNetworkLocationUrl(): string { + return process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL || DEFAULT_NETWORK_LOCATION_URL; +} + export function resolveNetworkLocation( data: NetworkLocationApiResponse, fallbackTimezone?: string diff --git a/frontend/tests/location.test.ts b/frontend/tests/location.test.ts index 2c251808..865b77fc 100644 --- a/frontend/tests/location.test.ts +++ b/frontend/tests/location.test.ts @@ -1,12 +1,40 @@ import { describe, expect, it } from 'vitest' import { + DEFAULT_NETWORK_LOCATION_URL, formatReverseGeocodedLocation, + getNetworkLocationUrl, getGeolocationFailureMessage, resolveNetworkLocation, } from '@/lib/location' describe('location helpers', () => { + it('uses the default network provider URL when no env override is set', () => { + const original = process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + + expect(getNetworkLocationUrl()).toBe(DEFAULT_NETWORK_LOCATION_URL) + + if (original === undefined) { + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + } else { + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = original + } + }) + + it('uses the configured network provider URL when provided', () => { + const original = process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = 'https://geo.example.com/json' + + expect(getNetworkLocationUrl()).toBe('https://geo.example.com/json') + + if (original === undefined) { + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + } else { + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = original + } + }) + it('resolves network location with formatted city label and timezone', () => { const result = resolveNetworkLocation({ success: true, From 0093e6c8df47962ed5074c83a6b4725122025fdd Mon Sep 17 00:00:00 2001 From: apple Date: Wed, 13 May 2026 02:45:24 +0100 Subject: [PATCH 5/7] fix: clean up PR validation issues Simplify exception handling in geocoding service. Fix unit system reference in settings page body measurements initialization. Co-Authored-By: Claude Opus 4.6 --- backend/app/services/weather_service.py | 5 ++--- frontend/app/dashboard/settings/page.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 738e65a6..a8e50fe9 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -2,7 +2,6 @@ import logging from dataclasses import dataclass from datetime import datetime -from json import JSONDecodeError import httpx import redis.asyncio as aioredis @@ -120,10 +119,10 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, response = await client.get("https://nominatim.openstreetmap.org/search", params=params) response.raise_for_status() data = response.json() - except (httpx.HTTPError, ValueError) as e: + except httpx.HTTPError as e: logger.error(f"Geocoding error for {query!r}: {e}") raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None - except (JSONDecodeError, ValueError) as e: + except ValueError as e: logger.error(f"Geocoding returned invalid JSON for {query!r}: {e}") raise GeocodingServiceError( f"Failed to decode geocoding response for location {query!r}: {e}" diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index 9b3bb1b5..d239075c 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { Loader2, Save, RotateCcw, Check, Plus, Trash2, ChevronUp, ChevronDown, Server, MapPin, Navigation, Ruler } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -197,7 +197,11 @@ export default function SettingsPage() { } return 'metric'; }); + const unitSystemRef = useRef(unitSystem); + useEffect(() => { + unitSystemRef.current = unitSystem; + }, [unitSystem]); useEffect(() => { if (userProfile) { @@ -209,9 +213,10 @@ export default function SettingsPage() { if (userProfile.body_measurements) { const initial: Record = {}; const numericKeys = ['chest', 'waist', 'hips', 'inseam', 'height', 'weight']; + const displayUnitSystem = unitSystemRef.current; for (const [key, value] of Object.entries(userProfile.body_measurements)) { if (numericKeys.includes(key) && typeof value === 'number') { - const converted = convertMeasurement(value, key, 'metric', unitSystem); + const converted = convertMeasurement(value, key, 'metric', displayUnitSystem); initial[key] = String(converted); } else { initial[key] = String(value); From 46aade02cf5b9f6e12b1e423dd0bcba1f25e2b0a Mon Sep 17 00:00:00 2001 From: apple Date: Wed, 13 May 2026 02:48:48 +0100 Subject: [PATCH 6/7] feat: add next-intl internationalization with 4 locales (en/zh/fr/it) Integrate next-intl for full i18n support across the frontend: - Add next-intl package and configure middleware + request config - Create locale switcher component with dropdown menu - Extract all hardcoded strings to translation files (967 keys) - Support English, Chinese, French, and Italian locales - Migrate all pages, components, dialogs, and cards to use useTranslations() - Replace hardcoded toast messages, titles, placeholders, and inline text - Add use-translated-constants hook for dynamic locale-aware constants - Add formatWornAgo i18n helper and location error message translations - Update tests with next-intl mock setup Co-Authored-By: Claude Opus 4.6 --- frontend/Dockerfile | 1 + frontend/app/dashboard/analytics/page.tsx | 64 +- frontend/app/dashboard/family/feed/page.tsx | 56 +- frontend/app/dashboard/family/page.tsx | 119 +- frontend/app/dashboard/history/page.tsx | 53 +- frontend/app/dashboard/layout.tsx | 4 +- frontend/app/dashboard/learning/page.tsx | 104 +- frontend/app/dashboard/notifications/page.tsx | 193 +-- frontend/app/dashboard/outfits/[id]/page.tsx | 31 +- frontend/app/dashboard/outfits/new/page.tsx | 80 +- frontend/app/dashboard/outfits/page.tsx | 64 +- frontend/app/dashboard/page.tsx | 117 +- frontend/app/dashboard/pairings/page.tsx | 27 +- frontend/app/dashboard/settings/page.tsx | 212 +-- frontend/app/dashboard/suggest/page.tsx | 88 +- frontend/app/dashboard/wardrobe/page.tsx | 107 +- frontend/app/error.tsx | 12 +- frontend/app/invite/page.tsx | 22 +- frontend/app/layout.tsx | 13 +- frontend/app/login/page.tsx | 47 +- frontend/app/not-found.tsx | 13 +- frontend/app/onboarding/page.tsx | 189 +-- frontend/app/page.tsx | 13 +- frontend/components/add-item-dialog.tsx | 101 +- frontend/components/bulk-action-toolbar.tsx | 19 +- frontend/components/color-eyedropper.tsx | 20 +- frontend/components/family-ratings.tsx | 19 +- frontend/components/feedback-dialog.tsx | 60 +- .../components/generate-pairings-dialog.tsx | 26 +- frontend/components/header.tsx | 13 +- frontend/components/image-lightbox.tsx | 4 +- frontend/components/item-detail-dialog.tsx | 156 +- frontend/components/locale-switcher.tsx | 61 + frontend/components/mobile-nav.tsx | 18 +- frontend/components/mobile-sidebar.tsx | 46 +- frontend/components/offline-indicator.tsx | 4 +- frontend/components/outfit-calendar.tsx | 6 +- frontend/components/outfit-history-card.tsx | 35 +- frontend/components/outfit-preview-dialog.tsx | 28 +- frontend/components/outfits/outfit-card.tsx | 29 +- frontend/components/pagination.tsx | 12 +- frontend/components/pairing-card.tsx | 14 +- .../shared/clone-to-lookbook-dialog.tsx | 20 +- frontend/components/shared/item-picker.tsx | 10 +- frontend/components/shared/lineage-card.tsx | 12 +- frontend/components/shared/occasion-chips.tsx | 6 +- frontend/components/sidebar.tsx | 44 +- frontend/components/studio/canvas-panel.tsx | 6 +- frontend/components/studio/details-panel.tsx | 40 +- frontend/components/ui/dropdown-menu.tsx | 51 + frontend/i18n/request.ts | 21 + .../lib/hooks/use-translated-constants.ts | 51 + frontend/lib/location.ts | 18 +- frontend/lib/utils.ts | 14 +- frontend/messages/en.json | 1291 ++++++++++++++++ frontend/messages/fr.json | 1347 +++++++++++++++++ frontend/messages/it.json | 1347 +++++++++++++++++ frontend/messages/zh.json | 1347 +++++++++++++++++ frontend/next.config.js | 5 +- frontend/package-lock.json | 699 ++++++++- frontend/package.json | 1 + frontend/tests/location.test.ts | 14 +- frontend/tests/setup.ts | 9 + frontend/tests/utils.test.ts | 55 +- 64 files changed, 7561 insertions(+), 1147 deletions(-) create mode 100644 frontend/components/locale-switcher.tsx create mode 100644 frontend/components/ui/dropdown-menu.tsx create mode 100644 frontend/i18n/request.ts create mode 100644 frontend/lib/hooks/use-translated-constants.ts create mode 100644 frontend/messages/en.json create mode 100644 frontend/messages/fr.json create mode 100644 frontend/messages/it.json create mode 100644 frontend/messages/zh.json diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2101e6fa..2ec78f7b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -37,6 +37,7 @@ RUN chown nextjs:nodejs .next # Automatically leverage output traces to reduce image size COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/messages ./messages USER nextjs diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx index 9047b321..5c7b3b45 100644 --- a/frontend/app/dashboard/analytics/page.tsx +++ b/frontend/app/dashboard/analytics/page.tsx @@ -16,6 +16,7 @@ import { Progress } from '@/components/ui/progress'; import { useAnalytics } from '@/lib/hooks/use-analytics'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; function StatCard({ title, @@ -195,14 +196,15 @@ function AcceptanceTrendChart({ data }: { data: { period: string; rate: number; } export default function AnalyticsPage() { + const t = useTranslations('analytics'); const { data, isLoading, isError } = useAnalytics(60); if (isLoading) { return (
-

Analytics

-

Your wardrobe insights and statistics

+

{t('title')}

+

{t('subtitle')}

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

Analytics

-

Your wardrobe insights and statistics

+

{t('title')}

+

{t('subtitle')}

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

No color data yet

+

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

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

No items yet

+

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

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

Start tracking your outfits!

+

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

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

Keep tracking!

+

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

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

All items have been worn!

+

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

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

Family Feed

+

{t('title')}

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

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

Join a family first

+

{t('noFamily.title')}

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

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

Family Feed

+

{t('title')}

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

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

No other members yet

+

{t('noMembers.title')}

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

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

Family Feed

+

{t('title')}

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

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

No outfits yet

+

{t('noOutfits.title')}

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

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

Family

+

{t('title')}

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

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

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

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

No recommendation history

+

{t('empty.title')}

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

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

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

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

History

+

{t('title')}

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

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

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

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

Loading your wardrobe...

+

{t('layout.loading')}

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

No Learning Data Yet

+

{t('noData.title')}

- Start by accepting or rejecting outfit suggestions and rating them. - The AI will learn from your feedback to make better recommendations. + {t('noData.description')}

- Already gave feedback? Click "Compute Now" to process it. + {t('noData.alreadyGaveFeedback')}

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

AI Learning

-

How the AI learns from your feedback

+

{t('title')}

+

{t('subtitle')}

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

AI Learning

+

{t('title')}

{profile.has_learning_data - ? 'The AI learns from your feedback to improve recommendations' - : 'Start rating outfits to help the AI learn your preferences'} + ? t('subtitle') + : t('subtitleEmpty')}

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

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

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

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

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

{pattern.occasion}

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

{pref.weather_type}

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

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

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

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

{CHANNEL_LABELS[setting.channel]}

+

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

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

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

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

- +

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

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

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

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

{day?.label}

+

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

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

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

{EMPTY_MESSAGES[chip]}

+

{t(EMPTY_KEYS[chip])}

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

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

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

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

)} {!selectedDate && (

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

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

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

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

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

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

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

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

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

No schedules set up

+

{t('nextScheduled.noSchedules')}

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

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

No channels configured

+

{t('notifications.noChannels')}

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

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

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

{wardrobe.outfits_this_week}

-

outfits

+

{t('weeklySummary.outfits')}

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

-

accepted

+

{t('weeklySummary.accepted')}

{wardrobe.average_rating && (

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

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

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

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

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

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

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

No pairings yet

+

{t('empty.title')}

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

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

- Pairings + {t('title')}

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

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

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

)}
@@ -130,7 +131,7 @@ export default function PairingsPage() { {isLoading ? ( ) : !data || data.pairings.length === 0 ? ( - + ) : ( <>
@@ -151,7 +152,7 @@ export default function PairingsPage() { variant="outline" onClick={() => setPage((p) => p + 1)} > - Load More + {t('loadMore')}
)} diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index d239075c..c13759e7 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -25,9 +25,11 @@ import { getGeolocationFailureMessage, 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; @@ -81,6 +83,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)); @@ -93,7 +97,7 @@ function ColorPicker({
- {CLOTHING_COLORS.map((color) => { + {clothingColors.map((color) => { const isSelected = selected.includes(color.value); return (
@@ -553,17 +559,17 @@ export default function SettingsPage() { {/* Account Section */} - Account - Your profile information + {t('account.title')} + {t('account.description')}
- +
- +
@@ -575,15 +581,15 @@ export default function SettingsPage() { - Location + {t('location.title')} - Set your location for weather-based outfit recommendations + {t('location.description')}
- + setLocationName(e.target.value)} @@ -592,7 +598,7 @@ export default function SettingsPage() {
- +
- +
- +
- +
{SIZE_FIELDS.map((field) => (
- + handleMeasurementChange(field.key, e.target.value)} - placeholder={field.placeholder} + placeholder={t(`body.sizePlaceholders.${field.key}` as 'body.sizePlaceholders.shirt_size')} />
))} @@ -737,9 +743,9 @@ export default function SettingsPage() { size="sm" > {updateUserProfile.isPending ? ( - <>Saving... + <>{t('body.saving')} ) : ( - <>Save Measurements + <>{t('body.saveMeasurements')} )} )} @@ -749,19 +755,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)} /> @@ -771,34 +777,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)} /> @@ -808,15 +814,15 @@ export default function SettingsPage() { {/* Temperature & Comfort */} - Temperature & Comfort + {t('temperature.title')} - Adjust how recommendations adapt to weather + {t('temperature.description')}
- +
- +
- +
@@ -880,7 +886,7 @@ export default function SettingsPage() { return ( <>
- +
- + - Recommendation Settings + {t('recommendations.title')} - Customize how outfit recommendations are generated + {t('recommendations.description')}
- +
- +
- +
- +
@@ -994,16 +1000,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')}

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

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

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

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

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

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

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

Location not set

+

{t('location.notSet')}

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

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

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

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

Your Outfit

+

{t('yourOutfit')}

{outfit.reasoning && (

{outfit.reasoning}

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

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

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

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

) : item.wear_count > 0 ? (

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

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

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

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

Your wardrobe is empty

+

{t('empty.title')}

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

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

My Wardrobe

+

{t('title')}

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

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

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

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

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

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

Something went wrong

+

{t('title')}

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

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

{getErrorMessage(joinByToken.error)}

+

{getErrorMessage(joinByToken.error, t)}

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

Backend Configuration Error

+

{t('backendError.title')}

{message}

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

wardrowbe

+

{t('title')}

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

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

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

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

404

-

Page not found

+

{t('title')}

+

{t('heading')}

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

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

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

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

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

Photograph your clothes

+

{t('welcome.feature1')}

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

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

Get personalized outfits

+

{t('welcome.feature2')}

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

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

Share with family

+

{t('welcome.feature3')}

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

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

Family Setup

+

{t('family.title')}

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

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

Invalid invite code

+

{t('family.error')}

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

Your Location

+

{t('location.title')}

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

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

Your Style

+

{t('style.title')}

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

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

Add Your First Item

+

{t('firstItem.title')}

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

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

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

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