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:
@@ -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) {
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<string, string> = {
|
||||
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) {
|
||||
<Play className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>Cook</TooltipContent>
|
||||
<TooltipContent>{m.recipe.cookAction}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
@@ -251,7 +245,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
{/* Serving scaler + ingredients */}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||
<h2 className="text-xl font-semibold">{m.recipe.ingredients}</h2>
|
||||
<ServingScaler
|
||||
baseServings={recipe.baseServings}
|
||||
recipeTitle={recipe.title}
|
||||
@@ -272,7 +266,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Instructions</h2>
|
||||
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
|
||||
<ol className="space-y-6">
|
||||
{recipe.steps.map((step, i) => (
|
||||
<li key={step.id} className="flex gap-4">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
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 */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>Public recipe by</span>
|
||||
<span>{m.publicRecipe.publicRecipeBy}</span>
|
||||
<Link href={`/u/${recipe.author.username ?? recipe.author.id}`} className="hover:text-foreground font-medium">
|
||||
{recipe.author.name}
|
||||
</Link>
|
||||
{session && (
|
||||
<div className="ml-auto">
|
||||
<Link href={`/recipes/${id}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
View in your library
|
||||
{m.publicRecipe.viewInLibrary}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!session && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>Sign up free</Link>
|
||||
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>Log in</Link>
|
||||
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
|
||||
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>{m.publicRecipe.logIn}</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -111,10 +111,10 @@ export default async function PublicRecipePage({ params }: Params) {
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
{recipe.difficulty && <Badge>{recipe.difficulty}</Badge>}
|
||||
<span className="flex items-center gap-1"><Users className="h-4 w-4" />{recipe.baseServings} servings</span>
|
||||
{recipe.prepMins && <span className="flex items-center gap-1"><Clock className="h-4 w-4" />{recipe.prepMins}m prep</span>}
|
||||
{recipe.cookMins && <span className="flex items-center gap-1"><ChefHat className="h-4 w-4" />{recipe.cookMins}m cook</span>}
|
||||
{totalMins > 0 && recipe.prepMins && recipe.cookMins && <span>({totalMins}m total)</span>}
|
||||
<span className="flex items-center gap-1"><Users className="h-4 w-4" />{formatMessage(m.recipe.servings, { count: recipe.baseServings })}</span>
|
||||
{recipe.prepMins && <span className="flex items-center gap-1"><Clock className="h-4 w-4" />{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}</span>}
|
||||
{recipe.cookMins && <span className="flex items-center gap-1"><ChefHat className="h-4 w-4" />{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}</span>}
|
||||
{totalMins > 0 && recipe.prepMins && recipe.cookMins && <span>({formatMessage(m.recipe.total, { mins: totalMins })})</span>}
|
||||
</div>
|
||||
{activeTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -133,7 +133,7 @@ export default async function PublicRecipePage({ params }: Params) {
|
||||
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||
<h2 className="text-xl font-semibold">{m.recipe.ingredients}</h2>
|
||||
<ServingScaler
|
||||
baseServings={recipe.baseServings}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
@@ -147,7 +147,7 @@ export default async function PublicRecipePage({ params }: Params) {
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Instructions</h2>
|
||||
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
|
||||
<ol className="space-y-6">
|
||||
{recipe.steps.map((step, i) => (
|
||||
<li key={step.id} className="flex gap-4">
|
||||
@@ -162,9 +162,9 @@ export default async function PublicRecipePage({ params }: Params) {
|
||||
|
||||
{!session && (
|
||||
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
|
||||
<p className="font-semibold">Want to save this recipe?</p>
|
||||
<p className="text-sm text-muted-foreground">Create a free account to save recipes, scale servings, and build your own library.</p>
|
||||
<Link href="/signup" className={cn(buttonVariants())}>Get started free</Link>
|
||||
<p className="font-semibold">{m.publicRecipe.wantToSave}</p>
|
||||
<p className="text-sm text-muted-foreground">{m.publicRecipe.wantToSaveDescription}</p>
|
||||
<Link href="/signup" className={cn(buttonVariants())}>{m.publicRecipe.getStartedFree}</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user