feat(i18n): translate hardcoded strings across print, public recipe, and recipe management UI

Wires dozens of components and server pages to next-intl instead of hardcoded
English text, and adds a lightweight getMessages/formatMessage helper for
server components (print pages, public recipe page, cook metadata) since
next-intl/server isn't wired into this app's routing. Backfills missing
en/fr message keys that existing code already referenced but fr.json lacked.
This commit is contained in:
Arnaud
2026-07-02 07:58:18 +02:00
parent afff6cf9eb
commit 01fdbb880b
32 changed files with 515 additions and 187 deletions
@@ -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<Tab>("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({
<Label>Difficulty</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder="Any" />
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>