diff --git a/apps/web/app/(app)/recipes/[id]/cook/page.tsx b/apps/web/app/(app)/recipes/[id]/cook/page.tsx index 06fff2d..c08cd36 100644 --- a/apps/web/app/(app)/recipes/[id]/cook/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/cook/page.tsx @@ -3,13 +3,16 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, recipes, eq, and } from "@epicure/db"; import { CookingMode } from "@/components/cooking-mode/cooking-mode"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; export async function generateMetadata({ params }: Params) { const { id } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + const m = getMessages((session?.user as { locale?: string } | undefined)?.locale); const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) }); - return { title: recipe ? `Cooking: ${recipe.title}` : "Cooking Mode" }; + return { title: recipe ? formatMessage(m.cookingMode.pageTitle, { title: recipe.title }) : m.cookingMode.pageTitleFallback }; } export default async function CookPage({ params }: Params) { diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 4487143..076ac3f 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -28,6 +28,7 @@ import { CommentsSection } from "@/components/social/comments-section"; import { getPublicUrl } from "@/lib/storage"; import { cn } from "@/lib/utils"; import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; +import { getMessages } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -37,25 +38,18 @@ export async function generateMetadata({ params }: Params): Promise { return { title: recipe?.title ?? "Recipe" }; } -const VISIBILITY_LABEL = { private: "Private", unlisted: "Unlisted", public: "Public" }; const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe }; const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const; -const DIETARY_LABELS: Record = { - vegan: "Vegan", - vegetarian: "Vegetarian", - glutenFree: "Gluten-free", - dairyFree: "Dairy-free", - nutFree: "Nut-free", - halal: "Halal", - kosher: "Kosher", -}; - export default async function RecipePage({ params }: Params) { const { id } = await params; const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + const VISIBILITY_LABEL = m.recipe.visibility; + const DIETARY_LABELS = m.recipe.dietary; + const [recipe, ratingData, favoriteData] = await Promise.all([ db.query.recipes.findFirst({ where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)), @@ -79,7 +73,7 @@ export default async function RecipePage({ params }: Params) { const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const activeDietaryTags = Object.entries(recipe.dietaryTags ?? {}) .filter(([, v]) => v) - .map(([k]) => DIETARY_LABELS[k]) + .map(([k]) => DIETARY_LABELS[k as keyof typeof DIETARY_LABELS]) .filter(Boolean); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; @@ -98,7 +92,7 @@ export default async function RecipePage({ params }: Params) { } /> - Cook + {m.recipe.cookAction} )} @@ -251,7 +245,7 @@ export default async function RecipePage({ params }: Params) { {/* Serving scaler + ingredients */} {recipe.ingredients.length > 0 && (
-

Ingredients

+

{m.recipe.ingredients}

-

Instructions

+

{m.recipe.instructions}

    {recipe.steps.map((step, i) => (
  1. diff --git a/apps/web/app/print/[id]/page.tsx b/apps/web/app/print/[id]/page.tsx index 067cfb2..990e545 100644 --- a/apps/web/app/print/[id]/page.tsx +++ b/apps/web/app/print/[id]/page.tsx @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth/server"; import { db, recipes, eq, and } from "@epicure/db"; import { PrintTrigger } from "@/components/recipe/print-trigger"; import { hasQuantity } from "@/lib/fractions"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -12,6 +13,8 @@ export default async function RecipePrintPage({ params }: Params) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + const recipe = await db.query.recipes.findFirst({ where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)), with: { @@ -24,19 +27,9 @@ export default async function RecipePrintPage({ params }: Params) { const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); - const DIETARY_LABELS: Record = { - vegan: "Vegan", - vegetarian: "Vegetarian", - glutenFree: "Gluten-free", - dairyFree: "Dairy-free", - nutFree: "Nut-free", - halal: "Halal", - kosher: "Kosher", - }; - const activeTags = Object.entries(recipe.dietaryTags ?? {}) .filter(([, v]) => v) - .map(([k]) => DIETARY_LABELS[k]) + .map(([k]) => m.recipe.dietary[k as keyof typeof m.recipe.dietary]) .filter(Boolean); return ( @@ -88,10 +81,10 @@ export default async function RecipePrintPage({ params }: Params) { )}
    - {recipe.baseServings && Serves {recipe.baseServings}} - {recipe.prepMins && Prep {recipe.prepMins} min} - {recipe.cookMins && Cook {recipe.cookMins} min} - {totalMins > 0 && Total {totalMins} min} + {recipe.baseServings && {formatMessage(m.recipe.servings, { count: recipe.baseServings })}} + {recipe.prepMins && {formatMessage(m.recipe.prep, { mins: recipe.prepMins })}} + {recipe.cookMins && {formatMessage(m.recipe.cook, { mins: recipe.cookMins })}} + {totalMins > 0 && {formatMessage(m.recipe.total, { mins: totalMins })}} {recipe.difficulty && {recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}}
    @@ -105,7 +98,7 @@ export default async function RecipePrintPage({ params }: Params) { {recipe.ingredients.length > 0 && ( <> -

    Ingredients

    +

    {m.recipe.ingredients}

      {recipe.ingredients.map((ing) => (
    • @@ -122,7 +115,7 @@ export default async function RecipePrintPage({ params }: Params) { {recipe.steps.length > 0 && ( <> -

      Instructions

      +

      {m.recipe.instructions}

        {recipe.steps.map((step) => (
      1. @@ -137,7 +130,7 @@ export default async function RecipePrintPage({ params }: Params) { )} -
        Printed from Epicure
        +
        {m.print.footer}
        ); } diff --git a/apps/web/app/print/meal-plan/page.tsx b/apps/web/app/print/meal-plan/page.tsx index 2141406..1bd3da5 100644 --- a/apps/web/app/print/meal-plan/page.tsx +++ b/apps/web/app/print/meal-plan/page.tsx @@ -2,16 +2,10 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, mealPlans, eq, and } from "@epicure/db"; import { PrintTrigger } from "@/components/recipe/print-trigger"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; -const DAY_LABELS: Record = { - mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", - fri: "Friday", sat: "Saturday", sun: "Sunday", -}; const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const; -const MEAL_LABELS: Record = { - breakfast: "Breakfast", lunch: "Lunch", dinner: "Dinner", snack: "Snack", -}; function getMonday(dateStr?: string): Date { const d = dateStr ? new Date(dateStr) : new Date(); @@ -31,6 +25,8 @@ export default async function MealPlanPrintPage({ const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + const monday = getMonday(week); const weekStart = monday.toISOString().slice(0, 10); const sunday = new Date(monday); @@ -82,22 +78,22 @@ export default async function MealPlanPrintPage({ -

        Meal Plan

        +

        {m.mealPlan.title}

        {label}

        - - {MEAL_ORDER.map((m) => ( - + + {MEAL_ORDER.map((meal) => ( + ))} {DAYS.map((day) => ( - + {MEAL_ORDER.map((mealType) => { const entry = entries.find((e) => e.day === day && e.mealType === mealType); return ( @@ -106,7 +102,7 @@ export default async function MealPlanPrintPage({ <> {entry.recipe?.title ?? entry.note ?? "—"} {entry.servings && ( - · {entry.servings} srv + {formatMessage(m.print.srv, { count: entry.servings })} )} ) : ( @@ -120,7 +116,7 @@ export default async function MealPlanPrintPage({
        Day{MEAL_LABELS[m]}{m.print.day}{m.mealPlan.meals[meal]}
        {DAY_LABELS[day]}{m.print.days[day]}
        -
        Printed from Epicure
        +
        {m.print.footer}
        ); } diff --git a/apps/web/app/print/shopping-list/[id]/page.tsx b/apps/web/app/print/shopping-list/[id]/page.tsx index b3dc2c8..3bdf209 100644 --- a/apps/web/app/print/shopping-list/[id]/page.tsx +++ b/apps/web/app/print/shopping-list/[id]/page.tsx @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, shoppingLists, eq, and } from "@epicure/db"; import { PrintTrigger } from "@/components/recipe/print-trigger"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -11,6 +12,9 @@ export default async function ShoppingListPrintPage({ params }: Params) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + const OTHER = m.mealPlan.aisleOther; + const list = await db.query.shoppingLists.findFirst({ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)), with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } }, @@ -19,13 +23,13 @@ export default async function ShoppingListPrintPage({ params }: Params) { if (!list) notFound(); const byAisle = list.items.reduce>((acc, item) => { - const key = item.aisle ?? "Other"; + const key = item.aisle ?? OTHER; (acc[key] ??= []).push(item); return acc; }, {}); const aisles = Object.keys(byAisle).sort((a, b) => - a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b) + a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b) ); return ( @@ -67,8 +71,11 @@ export default async function ShoppingListPrintPage({ params }: Params) {

        {list.name}

        - {list.items.filter(i => i.checked).length}/{list.items.length} items checked - {list.generatedAt ? " · From meal plan" : ""} + {formatMessage(m.shoppingLists.itemsChecked, { + checked: list.items.filter((i) => i.checked).length, + total: list.items.length, + })} + {list.generatedAt ? m.shoppingLists.fromMealPlan : ""}

        {aisles.map((aisle) => ( @@ -88,7 +95,7 @@ export default async function ShoppingListPrintPage({ params }: Params) { ))} -
        Printed from Epicure
        +
        {m.print.footer}
        ); } diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 718fd20..160128c 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -13,6 +13,7 @@ import { ServingScaler } from "@/components/recipe/serving-scaler"; import { getPublicUrl } from "@/lib/storage"; import { hasQuantity } from "@/lib/fractions"; import { cn } from "@/lib/utils"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -33,15 +34,12 @@ export async function generateMetadata({ params }: Params): Promise { }; } -const DIETARY_LABELS: Record = { - vegan: "Vegan", vegetarian: "Vegetarian", glutenFree: "Gluten-free", - dairyFree: "Dairy-free", nutFree: "Nut-free", halal: "Halal", kosher: "Kosher", -}; - export default async function PublicRecipePage({ params }: Params) { const { id } = await params; const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); + const m = getMessages((session?.user as { locale?: string } | undefined)?.locale); + const recipe = await db.query.recipes.findFirst({ where: (r, { and, eq, ne }) => and(eq(r.id, id), ne(r.visibility, "private")), with: { @@ -57,7 +55,9 @@ export default async function PublicRecipePage({ params }: Params) { const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0]; const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const activeTags = Object.entries(recipe.dietaryTags ?? {}) - .filter(([, v]) => v).map(([k]) => DIETARY_LABELS[k]).filter(Boolean); + .filter(([, v]) => v) + .map(([k]) => m.recipe.dietary[k as keyof typeof m.recipe.dietary]) + .filter(Boolean); const jsonLd = { "@context": "https://schema.org", @@ -85,21 +85,21 @@ export default async function PublicRecipePage({ params }: Params) { {/* Breadcrumb-style nav */}
        - Public recipe by + {m.publicRecipe.publicRecipeBy} {recipe.author.name} {session && (
        - View in your library + {m.publicRecipe.viewInLibrary}
        )} {!session && (
        - Sign up free - Log in + {m.publicRecipe.signUpFree} + {m.publicRecipe.logIn}
        )}
        @@ -111,10 +111,10 @@ export default async function PublicRecipePage({ params }: Params) { )}
        {recipe.difficulty && {recipe.difficulty}} - {recipe.baseServings} servings - {recipe.prepMins && {recipe.prepMins}m prep} - {recipe.cookMins && {recipe.cookMins}m cook} - {totalMins > 0 && recipe.prepMins && recipe.cookMins && ({totalMins}m total)} + {formatMessage(m.recipe.servings, { count: recipe.baseServings })} + {recipe.prepMins && {formatMessage(m.recipe.prep, { mins: recipe.prepMins })}} + {recipe.cookMins && {formatMessage(m.recipe.cook, { mins: recipe.cookMins })}} + {totalMins > 0 && recipe.prepMins && recipe.cookMins && ({formatMessage(m.recipe.total, { mins: totalMins })})}
        {activeTags.length > 0 && (
        @@ -133,7 +133,7 @@ export default async function PublicRecipePage({ params }: Params) { {recipe.ingredients.length > 0 && (
        -

        Ingredients

        +

        {m.recipe.ingredients}

        ({ @@ -147,7 +147,7 @@ export default async function PublicRecipePage({ params }: Params) { <>
        -

        Instructions

        +

        {m.recipe.instructions}

          {recipe.steps.map((step, i) => (
        1. @@ -162,9 +162,9 @@ export default async function PublicRecipePage({ params }: Params) { {!session && (
          -

          Want to save this recipe?

          -

          Create a free account to save recipes, scale servings, and build your own library.

          - Get started free +

          {m.publicRecipe.wantToSave}

          +

          {m.publicRecipe.wantToSaveDescription}

          + {m.publicRecipe.getStartedFree}
          )}
        diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index 8ab1467..9d94836 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import { Check, Package, Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -23,6 +24,8 @@ export function ShoppingListView({ listId: string; initialItems: Item[]; }) { + const t = useTranslations("mealPlan"); + const tShopping = useTranslations("shoppingLists"); const [items, setItems] = useState(initialItems); const [movingToPantry, setMovingToPantry] = useState(false); @@ -43,8 +46,8 @@ export function ShoppingListView({ })), }), }); - if (!res.ok) { toast.error("Failed to move items to pantry"); return; } - toast.success(`${checkedItems.length} item${checkedItems.length !== 1 ? "s" : ""} added to pantry`); + if (!res.ok) { toast.error(t("moveToPantryFailed")); return; } + toast.success(t("addedToPantry", { count: checkedItems.length })); } finally { setMovingToPantry(false); } @@ -61,7 +64,7 @@ export function ShoppingListView({ } const grouped = items.reduce>((acc, item) => { - const key = item.aisle ?? "Other"; + const key = item.aisle ?? t("aisleOther"); (acc[key] ??= []).push(item); return acc; }, {}); @@ -69,17 +72,17 @@ export function ShoppingListView({ const checkedCount = items.filter((i) => i.checked).length; if (items.length === 0) { - return

        This list is empty.

        ; + return

        {t("listEmptyState")}

        ; } return (
        -

        {checkedCount}/{items.length} checked

        +

        {tShopping("checkedCount", { checked: checkedCount, total: items.length })}

        {checkedCount > 0 && ( )}
        diff --git a/apps/web/components/recipe/adapt-recipe-button.tsx b/apps/web/components/recipe/adapt-recipe-button.tsx index 5a8ce12..bba3205 100644 --- a/apps/web/components/recipe/adapt-recipe-button.tsx +++ b/apps/web/components/recipe/adapt-recipe-button.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { Wand2, Loader2, X } from "lucide-react"; import { toast } from "sonner"; @@ -26,6 +27,7 @@ export function AdaptRecipeButton({ recipeId: string; ingredients: Ingredient[]; }) { + const t = useTranslations("recipe"); const router = useRouter(); const [open, setOpen] = useState(false); const [excluded, setExcluded] = useState>(new Set()); @@ -50,7 +52,7 @@ export function AdaptRecipeButton({ async function handleAdapt() { if (excluded.size === 0 && !extraConstraints.trim()) { - toast.error("Select at least one ingredient to exclude or add a constraint"); + toast.error(t("adaptConstraintRequired")); return; } @@ -68,7 +70,7 @@ export function AdaptRecipeButton({ if (!res.ok) { const err = await res.json() as { error?: string }; - toast.error(err.error ?? "Failed to adapt recipe"); + toast.error(err.error ?? t("adaptFailed")); return; } @@ -154,7 +156,7 @@ export function AdaptRecipeButton({ id="extra-constraints" value={extraConstraints} onChange={(e) => setExtraConstraints(e.target.value)} - placeholder="e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…" + placeholder={t("adaptConstraintPlaceholder")} rows={2} disabled={adapting} /> diff --git a/apps/web/components/recipe/add-to-shopping-list-button.tsx b/apps/web/components/recipe/add-to-shopping-list-button.tsx index 160059b..600fcd9 100644 --- a/apps/web/components/recipe/add-to-shopping-list-button.tsx +++ b/apps/web/components/recipe/add-to-shopping-list-button.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; import { ShoppingCart, Loader2, Plus, Check } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -45,6 +46,10 @@ export function AddToShoppingListButton({ baseServings: number; ingredients: Ingredient[]; }) { + const t = useTranslations("recipe"); + const tShopping = useTranslations("shoppingLists"); + const tCommon = useTranslations("common"); + const tForm = useTranslations("recipeForm"); const [open, setOpen] = useState(false); const [lists, setLists] = useState([]); const [loadingLists, setLoadingLists] = useState(false); @@ -91,7 +96,7 @@ export function AddToShoppingListButton({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newListName.trim() || recipeTitle }), }); - if (!res.ok) { toast.error("Failed to create list"); return; } + if (!res.ok) { toast.error(t("shoppingListCreateFailed")); return; } const created = await res.json() as { id: string }; listId = created.id; } @@ -102,9 +107,9 @@ export function AddToShoppingListButton({ body: JSON.stringify({ items }), }); - if (!res.ok) { toast.error("Failed to add ingredients"); return; } + if (!res.ok) { toast.error(t("shoppingListAddFailed")); return; } - toast.success(`${items.length} ingredients added to list`); + toast.success(tShopping("addedCount", { count: items.length })); setOpen(false); } finally { setAdding(false); @@ -120,7 +125,7 @@ export function AddToShoppingListButton({ } /> - Add to list + {tShopping("addToList")} @@ -129,17 +134,20 @@ export function AddToShoppingListButton({ - Add to shopping list + {tShopping("dialogTitle")} - Add the ingredients of {recipeTitle} to a shopping list. + {tShopping.rich("dialogDescription", { + title: recipeTitle, + strong: (chunks) => {chunks}, + })}
        {/* Servings scaler */}
        - +
        {scale !== 1 && ( - ({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} scaled) + ({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} {tShopping("scaledSuffix")} )}
        @@ -162,7 +170,7 @@ export function AddToShoppingListButton({ {/* List picker */} {loadingLists ? (
        - Loading your lists… + {tShopping("loadingLists")}
        ) : (
        @@ -170,7 +178,7 @@ export function AddToShoppingListButton({ setMode(v as "existing" | "new")}>
        - +
        {mode === "existing" && (
        @@ -192,7 +200,7 @@ export function AddToShoppingListButton({ )}
        - +
        )} @@ -202,7 +210,7 @@ export function AddToShoppingListButton({ setNewListName(e.target.value)} - placeholder="List name" + placeholder={tShopping("listNamePlaceholder")} />
        )} @@ -211,13 +219,13 @@ export function AddToShoppingListButton({ -

        {items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.

        +

        {tShopping("ingredientsWillBeAdded", { count: items.length })}

        - +
        diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index 6d8d0b7..a1f4282 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef } from "react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react"; import { toast } from "sonner"; @@ -69,6 +70,7 @@ export function AiGenerateDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { + const t = useTranslations("ai.generate"); const router = useRouter(); const [tab, setTab] = useState("describe"); @@ -122,7 +124,7 @@ export function AiGenerateDialog({ }); if (!res.ok) { const err = await res.json() as { error?: string }; - toast.error(err.error ?? "Failed to generate recipe"); + toast.error(err.error ?? t("error")); return; } const generated = await res.json() as GeneratedRecipe; @@ -131,9 +133,9 @@ export function AiGenerateDialog({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language }), }); - if (!saveRes.ok) { toast.error("Failed to save generated recipe"); return; } + if (!saveRes.ok) { toast.error(t("saveError")); return; } const saved = await saveRes.json() as { id: string }; - toast.success("Recipe generated! Review and edit before publishing."); + toast.success(t("success")); handleClose(); router.push(`/recipes/${saved.id}/edit`); } finally { @@ -151,13 +153,13 @@ export function AiGenerateDialog({ body: JSON.stringify({ imageBase64: photoBase64, mimeType: photoMime }), }); if (!res.ok) { - let msg = "Failed to analyze photo"; + let msg = t("photoError"); try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ } toast.error(msg); return; } const { id } = await res.json() as { id: string }; - toast.success("Recipe recognized! Review and edit before publishing."); + toast.success(t("photoSuccess")); handleClose(); router.push(`/recipes/${id}/edit`); } finally { @@ -226,7 +228,7 @@ export function AiGenerateDialog({ id="ai-prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} - placeholder="e.g. A creamy mushroom risotto with parmesan, serves 4, easy difficulty" + placeholder={t("placeholder")} rows={4} disabled={busy} /> @@ -249,7 +251,7 @@ export function AiGenerateDialog({ setInput(e.target.value)} - placeholder="Ask a question…" + placeholder={t("inputPlaceholder")} disabled={loading} className="flex-1" autoComplete="off" diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 24d1537..0253500 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; import { Trash2, Globe, Lock, Link2, X, Check, ListChecks } from "lucide-react"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -173,6 +174,7 @@ function SelectableRecipeCard({ } export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) { + const t = useTranslations("recipe"); const [recipes, setRecipes] = useState(initialRecipes); const [selectMode, setSelectMode] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -208,10 +210,10 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) }); if (!res.ok) throw new Error("Delete failed"); setRecipes((prev) => prev.filter((r) => !selected.has(r.id))); - toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} deleted`); + toast.success(t("bulkDeleted", { count: selected.size })); exitSelect(); } catch { - toast.error("Delete failed"); + toast.error(t("bulkDeleteFailed")); } finally { setBusy(false); } @@ -229,10 +231,10 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) setRecipes((prev) => prev.map((r) => selected.has(r.id) ? { ...r, visibility } : r) ); - toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} set to ${visibility}`); + toast.success(t("bulkVisibility", { count: selected.size, visibility })); exitSelect(); } catch { - toast.error("Update failed"); + toast.error(t("bulkUpdateFailed")); } finally { setBusy(false); } diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index a538b12..6c42248 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -22,13 +22,14 @@ import { UrlImportDialog } from "./url-import-dialog"; function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [local, setLocal] = useState(value); + const t = useTranslations("recipes"); return ( setLocal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }} onBlur={() => onChange(local)} - placeholder="Filter by tag…" + placeholder={t("filterByTag")} className="w-full h-7 px-2 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring" /> ); diff --git a/apps/web/components/recipe/serving-scaler.tsx b/apps/web/components/recipe/serving-scaler.tsx index 1cc65f1..d0c3c58 100644 --- a/apps/web/components/recipe/serving-scaler.tsx +++ b/apps/web/components/recipe/serving-scaler.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { Minus, Plus, Sparkles, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { scaleQuantity, hasQuantity } from "@/lib/fractions"; @@ -35,6 +36,7 @@ export function ServingScaler({ recipeId?: string; onAiScale?: (ingredients: ScaledIngredient[] | null) => void; }) { + const t = useTranslations("servingScaler"); const [servings, setServings] = useState(baseServings); const [aiScaledIngredients, setAiScaledIngredients] = useState(null); const [aiScaling, setAiScaling] = useState(false); @@ -67,7 +69,7 @@ export function ServingScaler({ return (
        - Servings + {t("servings")}
        )} {recipeId && servings !== baseServings && ( @@ -106,7 +108,7 @@ export function ServingScaler({ disabled={aiScaling} > - {aiScaling ? "Scaling…" : "AI Scale"} + {aiScaling ? t("scaling") : t("aiScale")} )}
        @@ -114,7 +116,7 @@ export function ServingScaler({ {aiScaledIngredients && (
        - AI-scaled quantities shown below + {t("aiScaledNote")}