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/api/weather.py b/backend/app/api/weather.py
index 665c68ae..66e09253 100644
--- a/backend/app/api/weather.py
+++ b/backend/app/api/weather.py
@@ -3,15 +3,18 @@
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")
@@ -54,18 +57,28 @@ 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:
+ 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
+
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 +110,28 @@ 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:
+ 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
+
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/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 9ba227f9..a8e50fe9 100644
--- a/backend/app/services/weather_service.py
+++ b/backend/app/services/weather_service.py
@@ -98,6 +98,49 @@ 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": settings.get_geocoding_user_agent(),
+ }
+
+ 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}")
+ raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None
+ 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}"
+ ) from 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)}"
@@ -349,3 +392,7 @@ async def check_health(self) -> dict:
class WeatherServiceError(Exception):
pass
+
+
+class GeocodingServiceError(WeatherServiceError):
+ pass
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/backend/tests/test_weather_api.py b/backend/tests/test_weather_api.py
new file mode 100644
index 00000000..3d6f4778
--- /dev/null
+++ b/backend/tests/test_weather_api.py
@@ -0,0 +1,106 @@
+from datetime import datetime
+from unittest.mock import AsyncMock, patch
+
+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
+ 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."
+ )
+
+ @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):
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')}
- Set Up Family
+ {t('noFamily.setUpFamily')}
@@ -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')}
- Manage Family
+ {t('noMembers.manageFamily')}
@@ -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')}
- Invite Members
+ {t('noMembers.inviteMembers')}
@@ -332,15 +337,15 @@ function FeedContent() {
{/* Header */}
-
Family Feed
+
{t('title')}
- Browse and rate your family members' outfits
+ {t('subtitle')}
- Manage Family
+ {t('noMembers.manageFamily')}
@@ -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..510aba10 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' ? (
) : (
setMode('create')} className="w-full">
- Create Family
+ {t('createFamily')}
)}
@@ -145,15 +147,15 @@ function NoFamilyView() {
- Join Family
+ {t('joinFamily')}
- Join an existing family with an invite code
+ {t('joinFamilyDesc')}
{mode === 'join' ? (
- Invite Code
+ {t('inviteCode')}
{joinFamily.isPending && }
- Join
+ {t('join')}
setMode(null)}>
- Cancel
+ {t('cancel')}
{joinFamily.isError && (
- Invalid invite code. Please check and try again.
+ {t('invalidInviteCode')}
)}
) : (
setMode('join')} variant="outline" className="w-full">
- Join Family
+ {t('joinFamily')}
)}
@@ -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('leaveFamily')}
- 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('confirmLeave')}
@@ -336,15 +337,15 @@ function FamilyView() {
setNewName(e.target.value)}
- placeholder="Family name"
+ placeholder={t('familyNamePlaceholder')}
onKeyDown={(e) => e.key === 'Enter' && handleUpdateName()}
/>
{updateFamily.isPending && }
- Save
+ {t('save')}
setEditingName(false)}>
- Cancel
+ {t('cancel')}
@@ -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')}
{inviteMember.isPending && }
- Invite
+ {t('sendInvite')}
@@ -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')}
- Open Family Feed
+ {t('familyOutfits.openFeed')}
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 (
);
}
-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() {
-
+
- All occasions
- Casual
- Office
- Formal
- Date
- Workout
+ {t('filters.allOccasions')}
+ {t('filters.casual')}
+ {t('filters.office')}
+ {t('filters.formal')}
+ {t('filters.date')}
+ {t('filters.workout')}
-
+
- All status
- Accepted
- Rejected
- Pending
- Viewed
+ {t('filters.allStatus')}
+ {t('filters.accepted')}
+ {t('filters.rejected')}
+ {t('filters.pending')}
+ {t('filters.viewed')}
@@ -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({
onAcknowledge(insight.id)}
className="absolute top-2 right-2 p-1 rounded hover:bg-muted transition-colors"
- title="Dismiss"
+ title={t('dismiss')}
>
@@ -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')}
- Get Outfit Suggestions
+ {t('noData.getOutfitSuggestions')}
void
className="w-full sm:w-auto"
>
- {isRefreshing ? 'Computing...' : 'Compute Now'}
+ {isRefreshing ? t('noData.computing') : t('noData.computeNow')}
- 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')}
- New Insights
+ {t('styleInsights.noData')}
@@ -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() {
- Update Preferences
+ {t('suggestedUpdates.updatePreferences')}
@@ -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..1e0aaf0c 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 })}
void;
userEmail?: string;
}) {
+ const t = useTranslations('notifications');
const [open, setOpen] = useState(false);
const [channel, setChannel] = useState<'ntfy' | 'mattermost' | 'email'>('ntfy');
const [config, setConfig] = useState>({});
@@ -204,15 +212,15 @@ function AddChannelDialog({
// Frontend validation
if (channel === 'ntfy' && !config.topic?.trim()) {
- toast.error('Topic is required for ntfy');
+ toast.error(t('channels.validation.topicRequired'));
return;
}
if (channel === 'mattermost' && !config.webhook_url?.trim()) {
- toast.error('Webhook URL is required for Mattermost');
+ toast.error(t('channels.validation.webhookRequired'));
return;
}
if (channel === 'email' && !config.address?.trim()) {
- toast.error('Email address is required');
+ toast.error(t('channels.validation.emailRequired'));
return;
}
@@ -244,20 +252,20 @@ function AddChannelDialog({
- Add Channel
+ {t('channels.addChannel')}