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, 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<ShoppingList[]>([]);
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({
<ShoppingCart className="h-4 w-4" />
</Button>
} />
<TooltipContent>Add to list</TooltipContent>
<TooltipContent>{tShopping("addToList")}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -129,17 +134,20 @@ export function AddToShoppingListButton({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-primary" />
Add to shopping list
{tShopping("dialogTitle")}
</DialogTitle>
<DialogDescription>
Add the ingredients of <strong>{recipeTitle}</strong> to a shopping list.
{tShopping.rich("dialogDescription", {
title: recipeTitle,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Servings scaler */}
<div className="flex items-center gap-3">
<Label className="shrink-0">Servings</Label>
<Label className="shrink-0">{tForm("servings")}</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
@@ -153,7 +161,7 @@ export function AddToShoppingListButton({
>+</Button>
</div>
{scale !== 1 && (
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} scaled)</span>
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} {tShopping("scaledSuffix")}</span>
)}
</div>
@@ -162,7 +170,7 @@ export function AddToShoppingListButton({
{/* List picker */}
{loadingLists ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading your lists
<Loader2 className="h-4 w-4 animate-spin" /> {tShopping("loadingLists")}
</div>
) : (
<div className="space-y-3">
@@ -170,7 +178,7 @@ export function AddToShoppingListButton({
<RadioGroup value={mode} onValueChange={(v: string) => setMode(v as "existing" | "new")}>
<div className="flex items-center gap-2">
<RadioGroupItem value="existing" id="mode-existing" />
<Label htmlFor="mode-existing">Add to existing list</Label>
<Label htmlFor="mode-existing">{tShopping("modeExisting")}</Label>
</div>
{mode === "existing" && (
<div className="ml-6 space-y-1.5">
@@ -192,7 +200,7 @@ export function AddToShoppingListButton({
)}
<div className="flex items-center gap-2">
<RadioGroupItem value="new" id="mode-new" />
<Label htmlFor="mode-new">Create new list</Label>
<Label htmlFor="mode-new">{tShopping("modeNew")}</Label>
</div>
</RadioGroup>
)}
@@ -202,7 +210,7 @@ export function AddToShoppingListButton({
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="List name"
placeholder={tShopping("listNamePlaceholder")}
/>
</div>
)}
@@ -211,13 +219,13 @@ export function AddToShoppingListButton({
<Separator />
<p className="text-xs text-muted-foreground">{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.</p>
<p className="text-xs text-muted-foreground">{tShopping("ingredientsWillBeAdded", { count: items.length })}</p>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={adding}>{tCommon("cancel")}</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding" : "Add ingredients"}
{adding ? tShopping("adding") : tShopping("addIngredients")}
</Button>
</div>
</div>