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
+11 -18
View File
@@ -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<string, string> = {
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) {
)}
<div className="meta">
{recipe.baseServings && <span>Serves {recipe.baseServings}</span>}
{recipe.prepMins && <span>Prep {recipe.prepMins} min</span>}
{recipe.cookMins && <span>Cook {recipe.cookMins} min</span>}
{totalMins > 0 && <span>Total {totalMins} min</span>}
{recipe.baseServings && <span>{formatMessage(m.recipe.servings, { count: recipe.baseServings })}</span>}
{recipe.prepMins && <span>{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}</span>}
{recipe.cookMins && <span>{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}</span>}
{totalMins > 0 && <span>{formatMessage(m.recipe.total, { mins: totalMins })}</span>}
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
</div>
@@ -105,7 +98,7 @@ export default async function RecipePrintPage({ params }: Params) {
{recipe.ingredients.length > 0 && (
<>
<h2>Ingredients</h2>
<h2>{m.recipe.ingredients}</h2>
<ul className="ingredients">
{recipe.ingredients.map((ing) => (
<li key={ing.id}>
@@ -122,7 +115,7 @@ export default async function RecipePrintPage({ params }: Params) {
{recipe.steps.length > 0 && (
<>
<h2>Instructions</h2>
<h2>{m.recipe.instructions}</h2>
<ol className="steps">
{recipe.steps.map((step) => (
<li key={step.id}>
@@ -137,7 +130,7 @@ export default async function RecipePrintPage({ params }: Params) {
)}
</article>
<footer>Printed from Epicure</footer>
<footer>{m.print.footer}</footer>
</>
);
}
+10 -14
View File
@@ -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<string, string> = {
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<string, string> = {
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({
<PrintTrigger />
<h1>Meal Plan</h1>
<h1>{m.mealPlan.title}</h1>
<p className="subtitle">{label}</p>
<table>
<thead>
<tr>
<th style={{ width: "100px" }}>Day</th>
{MEAL_ORDER.map((m) => (
<th key={m}>{MEAL_LABELS[m]}</th>
<th style={{ width: "100px" }}>{m.print.day}</th>
{MEAL_ORDER.map((meal) => (
<th key={meal}>{m.mealPlan.meals[meal]}</th>
))}
</tr>
</thead>
<tbody>
{DAYS.map((day) => (
<tr key={day}>
<td style={{ fontWeight: 600, color: "#555" }}>{DAY_LABELS[day]}</td>
<td style={{ fontWeight: 600, color: "#555" }}>{m.print.days[day]}</td>
{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({
<>
<span className="recipe-title">{entry.recipe?.title ?? entry.note ?? "—"}</span>
{entry.servings && (
<span className="servings"> · {entry.servings} srv</span>
<span className="servings">{formatMessage(m.print.srv, { count: entry.servings })}</span>
)}
</>
) : (
@@ -120,7 +116,7 @@ export default async function MealPlanPrintPage({
</tbody>
</table>
<footer>Printed from Epicure</footer>
<footer>{m.print.footer}</footer>
</>
);
}
+12 -5
View File
@@ -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<Record<string, typeof list.items>>((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) {
<h1>{list.name}</h1>
<p className="subtitle">
{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 : ""}
</p>
{aisles.map((aisle) => (
@@ -88,7 +95,7 @@ export default async function ShoppingListPrintPage({ params }: Params) {
</section>
))}
<footer>Printed from Epicure</footer>
<footer>{m.print.footer}</footer>
</>
);
}