From 2154512e5496883060171e9c840e56ad0a5fe13b Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 2 Jul 2026 08:25:51 +0200 Subject: [PATCH] fix(i18n): translate remaining recipe card/detail strings, localize AI responses Recipe cards, the recipe detail page, and meal planner still rendered raw "{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels outside the earlier i18n pass. Also wires the user's locale into AI features that previously always answered in English regardless of app language: recipe chat, ingredient substitution, recipe ideas, and generate-from-idea. The recipe-language picker in the AI generate dialog now defaults to the user's locale instead of always defaulting to English. --- apps/web/app/(app)/recipes/[id]/page.tsx | 12 ++++++------ apps/web/app/api/v1/ai/generate-from-idea/route.ts | 6 +++++- apps/web/app/api/v1/ai/recipe-chat/route.ts | 6 +++++- apps/web/app/api/v1/ai/recipe-ideas/route.ts | 6 ++++++ apps/web/app/api/v1/ai/substitute/route.ts | 5 ++++- apps/web/components/meal-plan/meal-planner.tsx | 2 +- apps/web/components/recipe/ai-generate-dialog.tsx | 4 +++- .../components/recipe/generate-content-button.tsx | 4 +++- apps/web/components/recipe/recipe-card.tsx | 6 ++++-- apps/web/components/recipe/recipes-grid.tsx | 5 +++-- apps/web/components/search/explore-page-content.tsx | 13 +++++++++---- apps/web/components/search/search-page-content.tsx | 9 +++++++-- apps/web/lib/ai/features/substitute-ingredient.ts | 8 ++++++-- apps/web/messages/en.json | 1 + apps/web/messages/fr.json | 1 + 15 files changed, 64 insertions(+), 24 deletions(-) diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 076ac3f..a7bff94 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -28,7 +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"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -175,26 +175,26 @@ export default async function RecipePage({ params }: Params) {
{recipe.difficulty && ( - {recipe.difficulty} + {m.recipe.difficulty[recipe.difficulty]} )} - {recipe.baseServings} servings + {formatMessage(m.recipe.servings, { count: recipe.baseServings })} {recipe.prepMins && ( - {recipe.prepMins}m prep + {formatMessage(m.recipe.prep, { mins: recipe.prepMins })} )} {recipe.cookMins && ( - {recipe.cookMins}m cook + {formatMessage(m.recipe.cook, { mins: recipe.cookMins })} )} {totalMins > 0 && recipe.prepMins && recipe.cookMins && ( - ({totalMins}m total) + ({formatMessage(m.recipe.total, { mins: totalMins })}) )} diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts index e3793ee..6c13e30 100644 --- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -14,6 +14,8 @@ const Schema = z.object({ model: z.string().optional(), }); +const LANG: Record = { en: "English", fr: "French" }; + export async function POST(req: NextRequest) { const { session, response } = await requireSession(); if (response) return response; @@ -30,11 +32,13 @@ export async function POST(req: NextRequest) { await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall"); const privateBio = await getUserPrivateBio(session!.user.id); + const locale = (session!.user as { locale?: string }).locale ?? "en"; const recipe = await generateRecipe(parsed.data.title, { provider: parsed.data.provider, model: parsed.data.model, userContext: privateBio ?? undefined, + language: LANG[locale] ?? "English", }); const recipeId = crypto.randomUUID(); @@ -50,7 +54,7 @@ export async function POST(req: NextRequest) { difficulty: recipe.difficulty ?? null, visibility: "private", aiGenerated: true, - language: "en", + language: locale, dietaryTags: recipe.dietaryTags ?? {}, tags: [], }); diff --git a/apps/web/app/api/v1/ai/recipe-chat/route.ts b/apps/web/app/api/v1/ai/recipe-chat/route.ts index b60216a..7efd05c 100644 --- a/apps/web/app/api/v1/ai/recipe-chat/route.ts +++ b/apps/web/app/api/v1/ai/recipe-chat/route.ts @@ -14,6 +14,8 @@ const Schema = z.object({ question: z.string().min(1).max(500), }); +const LANG: Record = { en: "English", fr: "French" }; + export async function POST(req: NextRequest) { const { session, response } = await requireSession(); if (response) return response; @@ -66,10 +68,12 @@ ${stepList || "None listed"} ]); const model = resolveModel(config); const bioContext = buildUserBioContext(privateBio); + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const lang = LANG[locale] ?? "English"; const { text } = await generateText({ model, - system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. + system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}. ${recipeContext}${bioContext}`, prompt: parsed.data.question, diff --git a/apps/web/app/api/v1/ai/recipe-ideas/route.ts b/apps/web/app/api/v1/ai/recipe-ideas/route.ts index ac688d5..6840063 100644 --- a/apps/web/app/api/v1/ai/recipe-ideas/route.ts +++ b/apps/web/app/api/v1/ai/recipe-ideas/route.ts @@ -11,6 +11,8 @@ const InputSchema = z.object({ prompt: z.string().max(300).optional(), }); +const LANG: Record = { en: "English", fr: "French" }; + const IdeasSchema = z.object({ ideas: z.array(z.object({ title: z.string(), @@ -45,6 +47,9 @@ export async function POST(req: NextRequest) { ? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n` : ""; + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const lang = LANG[locale] ?? "English"; + const prompt = parsed.data.prompt?.trim() ? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.` : `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`; @@ -52,6 +57,7 @@ export async function POST(req: NextRequest) { const { object } = await generateObject({ model, schema: IdeasSchema, + system: `Respond in ${lang}.`, prompt, }); diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index 470fec0..b5f399c 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -41,10 +41,13 @@ export async function POST(req: NextRequest) { ? { provider: parsed.data.provider, model: parsed.data.model } : await getDefaultProviderWithKey(session!.user.id); + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const substitutions = await substituteIngredient( parsed.data.ingredient, context, - aiConfig + aiConfig, + locale ); return NextResponse.json({ substitutions }); diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx index 847df2b..dfb7cdf 100644 --- a/apps/web/components/meal-plan/meal-planner.tsx +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -273,7 +273,7 @@ export function MealPlanner({ {entry?.recipe ? (
{entry.recipe.title}
-
{entry.servings} srv
+
{t("servingsAbbrev", { count: entry.servings })}
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 0253500..176d452 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -45,6 +45,7 @@ function SelectableRecipeCard({ selectMode: boolean; onToggle: (id: string) => void; }) { + const t = useTranslations("recipe"); const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0]; const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; @@ -139,12 +140,12 @@ function SelectableRecipeCard({ {totalMins > 0 && ( - {totalMins}m + {t("total", { mins: totalMins })} )} {recipe.difficulty && ( - {recipe.difficulty} + {t(`difficulty.${recipe.difficulty}`)} )}
diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index d565b9e..a20bdf0 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -43,8 +43,12 @@ type RecipeIdea = { }; function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) { + const t = useTranslations("recipe"); const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const description = "description" in recipe ? recipe.description : null; + const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard" + ? t(`difficulty.${recipe.difficulty}`) + : recipe.difficulty; return ( - {recipe.difficulty} + {difficultyLabel} )} @@ -70,7 +74,7 @@ function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResu {totalMins > 0 && ( - {totalMins}m + {t("total", { mins: totalMins })} )} {recipe.authorName && ( @@ -103,6 +107,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const searchParams = useSearchParams(); const t = useTranslations("explore"); const tCommon = useTranslations("common"); + const tRecipe = useTranslations("recipe"); const [inputValue, setInputValue] = useState(initialQuery); const [query, setQuery] = useState(initialQuery); @@ -388,7 +393,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { variant="secondary" className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`} > - {idea.difficulty} + {tRecipe(`difficulty.${idea.difficulty}`)}

{idea.description}

@@ -398,7 +403,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { ))} {idea.totalMins && ( - {idea.totalMins}m + {tRecipe("total", { mins: idea.totalMins })} )} diff --git a/apps/web/components/search/search-page-content.tsx b/apps/web/components/search/search-page-content.tsx index b35b492..1cacf4c 100644 --- a/apps/web/components/search/search-page-content.tsx +++ b/apps/web/components/search/search-page-content.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -39,7 +40,11 @@ const DIFFICULTY_COLORS: Record = { }; function RecipeCard({ recipe }: { recipe: RecipeResult }) { + const t = useTranslations("recipe"); const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard" + ? t(`difficulty.${recipe.difficulty}`) + : recipe.difficulty; return ( - {recipe.difficulty} + {difficultyLabel} )} @@ -65,7 +70,7 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) { {totalMins > 0 && ( - {totalMins}m + {t("total", { mins: totalMins })} )} {recipe.authorName && ( diff --git a/apps/web/lib/ai/features/substitute-ingredient.ts b/apps/web/lib/ai/features/substitute-ingredient.ts index 8eccc3f..49a4cf4 100644 --- a/apps/web/lib/ai/features/substitute-ingredient.ts +++ b/apps/web/lib/ai/features/substitute-ingredient.ts @@ -13,18 +13,22 @@ const SubstitutionsSchema = z.object({ export type Substitution = z.infer["substitutions"][number]; +const LANG: Record = { en: "English", fr: "French" }; + export async function substituteIngredient( ingredient: string, recipeContext: string, - config?: AiConfig + config?: AiConfig, + locale?: string ): Promise { const model = resolveModel(config); + const lang = LANG[locale ?? "en"] ?? "English"; const { object } = await generateObject({ model, schema: SubstitutionsSchema, system: - "You are a professional chef specializing in ingredient substitutions. Provide practical, commonly available substitutes. For each substitute: name what to use, the ratio (e.g. '1:1', '3/4 the amount'), and a brief note on technique adjustments. Rate the flavor impact honestly.", + `You are a professional chef specializing in ingredient substitutions. Provide practical, commonly available substitutes. For each substitute: name what to use, the ratio (e.g. '1:1', '3/4 the amount'), and a brief note on technique adjustments. Rate the flavor impact honestly. Respond in ${lang}.`, prompt: `Suggest 3-4 substitutes for "${ingredient}" in this recipe context: ${recipeContext}`, }); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 502cd21..6c91f8c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -335,6 +335,7 @@ "addTo": "Add to {day} – {meal}", "searchRecipes": "Search recipes…", "servings": "Servings", + "servingsAbbrev": "{count} srv", "difficulty": "Difficulty", "noRecipes": "No recipes found", "addFailed": "Failed to add", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index cc1053a..f34855d 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -323,6 +323,7 @@ "addTo": "Ajouter à {day} – {meal}", "searchRecipes": "Rechercher des recettes…", "servings": "Portions", + "servingsAbbrev": "{count} pers.", "difficulty": "Difficulté", "noRecipes": "Aucune recette trouvée", "addFailed": "Échec de l'ajout",