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.
This commit is contained in:
@@ -273,7 +273,7 @@ export function MealPlanner({
|
||||
{entry?.recipe ? (
|
||||
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
||||
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
|
||||
<div className="text-muted-foreground mt-0.5">{entry.servings} srv</div>
|
||||
<div className="text-muted-foreground mt-0.5">{t("servingsAbbrev", { count: entry.servings })}</div>
|
||||
<button
|
||||
onClick={() => removeEntry(entry)}
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { useLocale } from "@/lib/i18n/provider";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
@@ -72,11 +73,12 @@ export function AiGenerateDialog({
|
||||
}) {
|
||||
const t = useTranslations("ai.generate");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [tab, setTab] = useState<Tab>("describe");
|
||||
|
||||
// describe tab
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [language, setLanguage] = useState(locale);
|
||||
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
||||
|
||||
// photo tab
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { useLocale } from "@/lib/i18n/provider";
|
||||
|
||||
type GeneratedRecipe = {
|
||||
ingredients: Array<{ rawName: string; quantity?: number; unit?: string; note?: string }>;
|
||||
@@ -24,6 +25,7 @@ export function GenerateContentButton({
|
||||
}) {
|
||||
const t = useTranslations("ai.generate");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function generate() {
|
||||
@@ -36,7 +38,7 @@ export function GenerateContentButton({
|
||||
const genRes = await fetch("/api/v1/ai/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt }),
|
||||
body: JSON.stringify({ prompt, language: locale }),
|
||||
});
|
||||
|
||||
if (!genRes.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
@@ -30,6 +31,7 @@ const DIFFICULTY_COLOR = {
|
||||
} as const;
|
||||
|
||||
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
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];
|
||||
@@ -68,12 +70,12 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
{totalMins > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{totalMins}m
|
||||
{t("total", { mins: totalMins })}
|
||||
</span>
|
||||
)}
|
||||
{recipe.difficulty && (
|
||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4">
|
||||
{recipe.difficulty}
|
||||
{t(`difficulty.${recipe.difficulty}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{totalMins}m
|
||||
{t("total", { mins: totalMins })}
|
||||
</span>
|
||||
)}
|
||||
{recipe.difficulty && (
|
||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">
|
||||
{recipe.difficulty}
|
||||
{t(`difficulty.${recipe.difficulty}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={`/recipes/${recipe.id}`}
|
||||
@@ -59,7 +63,7 @@ function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResu
|
||||
variant="secondary"
|
||||
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
||||
>
|
||||
{recipe.difficulty}
|
||||
{difficultyLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,7 +74,7 @@ function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResu
|
||||
{totalMins > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{totalMins}m
|
||||
{t("total", { mins: totalMins })}
|
||||
</span>
|
||||
)}
|
||||
{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}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
|
||||
@@ -398,7 +403,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
||||
))}
|
||||
{idea.totalMins && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />{idea.totalMins}m
|
||||
<Clock className="h-3 w-3" />{tRecipe("total", { mins: idea.totalMins })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
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 (
|
||||
<Link
|
||||
href={`/recipes/${recipe.id}`}
|
||||
@@ -54,7 +59,7 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
||||
variant="secondary"
|
||||
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
||||
>
|
||||
{recipe.difficulty}
|
||||
{difficultyLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -65,7 +70,7 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
||||
{totalMins > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{totalMins}m
|
||||
{t("total", { mins: totalMins })}
|
||||
</span>
|
||||
)}
|
||||
{recipe.authorName && (
|
||||
|
||||
Reference in New Issue
Block a user