feat: recipe type (dish/drink) for better cocktail handling (v0.31.0)

Add recipes.recipeType enum (dish|drink, default dish). Recipe form
now shows a Type selector and hides food-only fields for drinks
(prep/cook time, difficulty, batch-cook toggle) instead of forcing
every cocktail through meal-oriented UI. Detail page skips the
meal/drink pairing suggestion buttons on drink recipes (pairing a
drink with a drink doesn't make sense). Recipes list gains a Type
filter facet alongside difficulty/batch-cook.

Scoped deliberately narrow per investigation: no ABV/glass-type/
garnish structured fields, no meal-plan schema changes, and the
existing AI drink-pairing-suggestion feature (ephemeral, never saved
as a recipe) is left as-is rather than wired into recipe creation —
those suggestions are wine/beer/spirit recommendations without
ingredients/steps, not really recipes to save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 14:16:43 +02:00
parent 894784afda
commit 6f11dc07fd
18 changed files with 5215 additions and 58 deletions
+72 -47
View File
@@ -66,6 +66,7 @@ type RecipeFormProps = {
title?: string;
description?: string;
baseServings?: number;
recipeType?: "dish" | "drink";
visibility?: "private" | "unlisted" | "public";
difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null;
@@ -103,6 +104,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [recipeType, setRecipeType] = useState<"dish" | "drink">(defaultValues?.recipeType ?? "dish");
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
@@ -150,6 +152,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
photos,
isBatchCook,
dishes,
recipeType,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
@@ -301,6 +304,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
title: title.trim(),
description: description.trim() || undefined,
baseServings: parseInt(baseServings) || 4,
recipeType,
visibility,
difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined,
@@ -365,9 +369,22 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="recipeType">{t("recipeType")}</Label>
<select
id="recipeType"
value={recipeType}
onChange={(e) => setRecipeType(e.target.value as "dish" | "drink")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="dish">{t("recipeTypeDish")}</option>
<option value="drink">{t("recipeTypeDrink")}</option>
</select>
</div>
<div className={cn("grid grid-cols-2 gap-4", recipeType === "dish" && "md:grid-cols-4")}>
<div className="space-y-2">
<Label htmlFor="servings">{t("servings")}</Label>
<Label htmlFor="servings">{recipeType === "drink" ? t("servingsDrink") : t("servings")}</Label>
<Input
id="servings"
type="number"
@@ -377,42 +394,46 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
onChange={(e) => setBaseServings(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="prep">{t("prepMins")}</Label>
<Input
id="prep"
type="number"
min={0}
value={prepMins}
onChange={(e) => setPrepMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cook">{t("cookMins")}</Label>
<Input
id="cook"
type="number"
min={0}
value={cookMins}
onChange={(e) => setCookMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">{t("difficulty")}</Label>
<select
id="difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value=""></option>
<option value="easy">{t_recipe("difficulty.easy")}</option>
<option value="medium">{t_recipe("difficulty.medium")}</option>
<option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
{recipeType === "dish" && (
<>
<div className="space-y-2">
<Label htmlFor="prep">{t("prepMins")}</Label>
<Input
id="prep"
type="number"
min={0}
value={prepMins}
onChange={(e) => setPrepMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cook">{t("cookMins")}</Label>
<Input
id="cook"
type="number"
min={0}
value={cookMins}
onChange={(e) => setCookMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">{t("difficulty")}</Label>
<select
id="difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value=""></option>
<option value="easy">{t_recipe("difficulty.easy")}</option>
<option value="medium">{t_recipe("difficulty.medium")}</option>
<option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-2">
@@ -477,16 +498,20 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
</div>
<Separator />
{recipeType === "dish" && (
<>
<Separator />
{/* Batch cooking */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
</div>
{/* Batch cooking */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
</div>
</>
)}
<Separator />
+26 -2
View File
@@ -71,6 +71,12 @@ const BATCH_COOK_KEYS: Record<string, string> = {
"0": "batchCookExclude",
};
const RECIPE_TYPE_KEYS: Record<string, string> = {
"": "all",
dish: "dish",
drink: "drink",
};
export function RecipesHeader({
count,
initialQuery = "",
@@ -79,6 +85,7 @@ export function RecipesHeader({
initialDifficulty = "",
initialTag = "",
initialBatchCook = "",
initialRecipeType = "",
}: {
count: number;
initialQuery?: string;
@@ -87,6 +94,7 @@ export function RecipesHeader({
initialDifficulty?: string;
initialTag?: string;
initialBatchCook?: string;
initialRecipeType?: string;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -106,6 +114,7 @@ export function RecipesHeader({
difficulty: initialDifficulty,
tag: initialTag,
batchCook: initialBatchCook,
recipeType: initialRecipeType,
...overrides,
};
if (current.q?.trim()) params.set("q", current.q.trim());
@@ -114,6 +123,7 @@ export function RecipesHeader({
if (current.difficulty) params.set("difficulty", current.difficulty);
if (current.tag?.trim()) params.set("tag", current.tag.trim());
if (current.batchCook) params.set("batchCook", current.batchCook);
if (current.recipeType) params.set("recipeType", current.recipeType);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
@@ -122,7 +132,7 @@ export function RecipesHeader({
pushParams({ q: value });
}
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook].filter(Boolean).length;
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook, initialRecipeType].filter(Boolean).length;
const sortChanged = initialSort !== "updated_desc";
return (
@@ -270,12 +280,26 @@ export function RecipesHeader({
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>{t("recipeTypeLabel")}</DropdownMenuLabel>
{Object.entries(RECIPE_TYPE_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
closeOnClick={false}
onClick={() => pushParams({ recipeType: value })}
className={cn(initialRecipeType === value && "font-medium text-primary")}
>
{value === "" ? t(key) : tRecipe(`recipeType.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "" })}
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "", recipeType: "" })}
className="text-muted-foreground"
>
<X className="h-3 w-3 mr-1.5" /> {t("clearFilters")}