diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a1ce2f..2e876ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,10 @@ import { motion } from "framer-motion"; import { Calendar, Package, Sparkles } from "lucide-react"; import { AuthResponse, clearToken, getAuthConfig, getMe, storeToken } from "./api/client"; import AccountMenu from "./components/AccountMenu"; +import LanguageSwitcher from "./components/LanguageSwitcher"; import { AboutModal, ChangePasswordModal, DeleteAccountModal, FeedbackModal, InviteHouseholdModal } from "./components/modals"; import { useRecommendationsQuery } from "./hooks/queries"; +import { useI18n } from "./i18n"; import { topRecommendation } from "./lib/recommendation"; import DashboardPage from "./pages/Dashboard"; import MealPlannerPage from "./pages/MealPlanner"; @@ -14,6 +16,7 @@ import { AuthUser } from "./types"; export default function App() { const queryClient = useQueryClient(); + const { t } = useI18n(); const [authUser, setAuthUser] = useState(null); const [authChecked, setAuthChecked] = useState(false); const [page, setPage] = useState<"home" | "planner">("home"); @@ -99,7 +102,7 @@ export default function App() { if (!authChecked) { return (
- Loading... + {t("common.loading")}
); } @@ -140,36 +143,39 @@ export default function App() {

ONS Inventory

-

Welcome, {authUser.firstName}!

+

{t("header.welcome", { name: authUser.firstName })}

- Track pantry, fridge, and freezer stock and get recipe ideas before ingredients expire. + {t("header.tagline")}

{authUser.householdName && (

{authUser.householdName}

)}
- setDark((d) => !d)} - onInvite={() => setShowInviteHousehold(true)} - onChangePassword={() => setShowChangePassword(true)} - onFeedback={() => setShowFeedback(true)} - onAbout={() => setShowAbout(true)} - onLogout={handleLogout} - onDeleteAccount={() => setShowDeleteAccount(true)} - /> +
+ + setDark((d) => !d)} + onInvite={() => setShowInviteHousehold(true)} + onChangePassword={() => setShowChangePassword(true)} + onFeedback={() => setShowFeedback(true)} + onAbout={() => setShowAbout(true)} + onLogout={handleLogout} + onDeleteAccount={() => setShowDeleteAccount(true)} + /> +
{topMatch ? ( <> -
Top suggestion
+
{t("header.topSuggestion")}
{topMatch.recipe.name} ({topMatch.matchPercentage}%)
) : ( -
Add inventory to unlock recommendations
+
{t("header.noSuggestion")}
)}
@@ -185,7 +191,7 @@ export default function App() { }`} > - Inventory + {t("nav.inventory")} diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index da26ce1..33f5209 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { CircleUser, Info, KeyRound, LogOut, Menu, MessageSquare, Moon, Sun, Trash2, UserPlus, X } from "lucide-react"; +import { useI18n } from "../i18n"; -const itemClass = +export const itemClass = "flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"; -function Dropdown({ +export function Dropdown({ label, icon, openIcon, @@ -41,7 +42,7 @@ function Dropdown({
)} - } openIcon={}> + } openIcon={}> {(close) => ( <> )} diff --git a/frontend/src/components/InventorySection.tsx b/frontend/src/components/InventorySection.tsx index 90e3fa9..99363aa 100644 --- a/frontend/src/components/InventorySection.tsx +++ b/frontend/src/components/InventorySection.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Pencil, Refrigerator, Trash2, Undo2, X } from "lucide-react"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { addInventoryItem, deleteInventoryItem, updateInventoryItem } from "../api/client"; +import { useI18n } from "../i18n"; import { isExpiring } from "../lib/inventory"; import { inputClass } from "../lib/ui"; import { InventoryItem, Location } from "../types"; @@ -43,6 +44,7 @@ export default function InventorySection({ expiryFilter: "expiring" | "expired" | null; onClearExpiryFilter: () => void; }) { + const { t } = useI18n(); const queryClient = useQueryClient(); const [formState, setFormState] = useState(initialForm); const [editingItemId, setEditingItemId] = useState(null); @@ -162,7 +164,7 @@ export default function InventorySection({
-

Inventory by location

+

{t("inventory.title")}

{expiryFilter && !searchQuery && ( )}
{!searchQuery && (
- {["All", ...locations.map((location) => location.name)].map((name) => ( + {[{ name: "All", label: t("inventory.all") }, ...locations.map((location) => ({ name: location.name, label: location.name }))].map(({ name, label }) => ( ))}
@@ -200,7 +202,7 @@ export default function InventorySection({
{filteredInventory.length === 0 ? ( -

No items in this location yet.

+

{t("inventory.empty")}

) : ( paginatedInventory.map((item) => (
@@ -220,7 +222,7 @@ export default function InventorySection({ : "bg-emerald-500/20 text-emerald-700 dark:text-emerald-200" }`} > - {item.expired ? "Expired" : isExpiring(item) ? "Expiring" : "Fresh"} + {item.expired ? t("inventory.status.expired") : isExpiring(item) ? t("inventory.status.expiring") : t("inventory.status.fresh")}
@@ -230,7 +232,7 @@ export default function InventorySection({ className="inline-flex items-center gap-1 rounded-md bg-slate-200 dark:bg-slate-700 px-2 py-1 text-xs text-slate-700 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-600" > - Edit + {t("common.edit")}
@@ -249,7 +251,7 @@ export default function InventorySection({ {filteredInventory.length > 0 && (
- Items per page + {t("pagination.itemsPerPage")} setFormState((prev) => ({ ...prev, name: event.target.value }))} className={inputClass} - placeholder="Item name" + placeholder={t("inventory.form.name")} />
setFormState((prev) => ({ ...prev, category: event.target.value }))} className={inputClass} - placeholder="Category" + placeholder={t("inventory.form.category")} /> setFormState((prev) => ({ ...prev, unit: event.target.value }))} className={inputClass} - placeholder="Unit" + placeholder={t("inventory.form.unit")} />
setFormState((prev) => ({ ...prev, notes: event.target.value }))} className={inputClass} - placeholder="Notes (optional)" + placeholder={t("inventory.form.notes")} /> {editingItemId !== null && ( )} {addItemMutation.isError && ( -

Could not add item. Check backend availability.

+

{t("inventory.error.add")}

)} {updateItemMutation.isError && ( -

Could not update item. Try again.

+

{t("inventory.error.update")}

)} {deleteItemMutation.isError && ( -

Could not delete item. Try again.

+

{t("inventory.error.delete")}

)}
-

Stock distribution

+

{t("inventory.chart.title")}

{locationDistribution.length === 0 ? ( -

Add a few items to see the chart.

+

{t("inventory.chart.empty")}

) : ( diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..7ed97f0 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,32 @@ +import { Check } from "lucide-react"; +import { languages, useI18n } from "../i18n"; +import { Dropdown, itemClass } from "./AccountMenu"; + +export default function LanguageSwitcher() { + const { lang, setLang, t } = useI18n(); + const current = languages.find((language) => language.code === lang) ?? languages[0]; + + return ( + {current.flag}}> + {(close) => ( + <> + {languages.map((language) => ( + + ))} + + )} + + ); +} diff --git a/frontend/src/components/RecipeDetailModal.tsx b/frontend/src/components/RecipeDetailModal.tsx index c6488f7..c099ea0 100644 --- a/frontend/src/components/RecipeDetailModal.tsx +++ b/frontend/src/components/RecipeDetailModal.tsx @@ -2,12 +2,14 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Undo2, UtensilsCrossed, X } from "lucide-react"; import { checkRecipeAvailability, cookRecipe } from "../api/client"; +import { difficultyLabel, useI18n } from "../i18n"; import { parseInstructionSteps } from "../lib/recipeText"; import { CookResult, Recipe } from "../types"; import { ModalShell } from "./modals"; // Mount with key={recipe.id} so servings/skips/cook state reset per recipe. export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) { + const { t } = useI18n(); const queryClient = useQueryClient(); const baseServings = recipe.servings ?? 1; const [servings, setServings] = useState(baseServings); @@ -48,7 +50,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe;

{recipe.name}

- {recipe.cuisine || "Unknown cuisine"} · {recipe.difficulty || "unknown"} + {recipe.cuisine || t("recipes.unknownCuisine")} · {difficultyLabel(t, recipe.difficulty)}

- Servings: + {t("detail.servings")}
{servings !== baseServings && ( - (recipe is for {baseServings}) + {t("detail.baseServings", { count: baseServings })} )}
-

Ingredients

+

{t("detail.ingredients")}

    {(recipe.ingredients ?? []).map((ingredient, index) => { const scaled = ingredient.quantity * scale; @@ -110,7 +112,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe;
-

Step-by-step

+

{t("detail.steps")}

    {parseInstructionSteps(recipe.instructions).map((step, index) => (
  1. {step}
  2. @@ -139,7 +141,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe;
    {cookResult.consumed.length > 0 && (
    -

    Inventory updated:

    +

    {t("detail.inventoryUpdated")}

      {cookResult.consumed.map((line) =>
    • · {line}
    • )}
    @@ -147,7 +149,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; )} {cookResult.unmatched.length > 0 && (
    -

    Not found in inventory:

    +

    {t("detail.notFoundInInventory")}

      {cookResult.unmatched.map((line) =>
    • · {line}
    • )}
    @@ -163,25 +165,25 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 font-medium text-white transition hover:bg-brand-500 disabled:cursor-not-allowed disabled:opacity-40" > - {cookRecipeMutation.isPending ? "Updating inventory..." : "Cooked this!"} + {cookRecipeMutation.isPending ? t("detail.cooking") : t("detail.cooked")} {(filteredInsufficient.length > 0 || filteredMissing.length > 0) && (
    {filteredInsufficient.map((line) => ( -

    · Not enough: {line}

    +

    · {t("recommendations.notEnough", { items: line })}

    ))} {filteredMissing.map((line) => ( -

    · Missing: {line}

    +

    · {t("recommendations.missing", { items: line })}

    ))}

    - Click × next to an ingredient above to skip it for this cook. + {t("detail.skipHint")}

    )}
    )} {cookRecipeMutation.isError && ( -

    Could not update inventory. Try again.

    +

    {t("detail.error.cook")}

    )}
    diff --git a/frontend/src/components/RecipesSection.tsx b/frontend/src/components/RecipesSection.tsx index 258a472..1922a59 100644 --- a/frontend/src/components/RecipesSection.tsx +++ b/frontend/src/components/RecipesSection.tsx @@ -2,6 +2,7 @@ import { FormEvent, useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Pencil, Trash2, Undo2 } from "lucide-react"; import { addRecipe, deleteRecipe, updateRecipe } from "../api/client"; +import { difficultyLabel, useI18n } from "../i18n"; import { parseIngredientLine } from "../lib/recipeText"; import { inputClass } from "../lib/ui"; import { Recipe, RecipeIngredient } from "../types"; @@ -31,6 +32,7 @@ export default function RecipesSection({ searchQuery: string; onSelectRecipe: (recipe: Recipe) => void; }) { + const { t } = useI18n(); const queryClient = useQueryClient(); const [recipeForm, setRecipeForm] = useState(initialRecipeForm); const [editingRecipeId, setEditingRecipeId] = useState(null); @@ -121,7 +123,7 @@ export default function RecipesSection({

    - {editingRecipeId !== null ? "Edit recipe" : "Add recipe"} + {editingRecipeId !== null ? t("recipes.editTitle") : t("recipes.addTitle")}

    setRecipeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputClass} - placeholder="Recipe name" + placeholder={t("recipes.form.name")} />
    setRecipeForm((prev) => ({ ...prev, cuisine: event.target.value }))} className={inputClass} - placeholder="Cuisine" + placeholder={t("recipes.form.cuisine")} />