A recipe was previously a dead end: you could save one and see whether your bar could make it, but there was no way to record that you had actually drunk it. Promoting links rather than converts. A drink is created and Recipe.sourceDrinkId is set to point at it, so the recipe survives and then renders on that drink's page through the relation the drink detail view already reads. The wishlist path creates a "try later" entry tagged source=recipe and leaves the recipe alone. Both are idempotent. Promoting an already-linked recipe returns the existing drink with a 200 rather than creating a second one, because pressing the button twice is a double click and not a request for a duplicate. Creation and linking happen in one transaction - a drink that existed but was not linked back would show no recipe and would be promoted again on the next attempt. The logic lives in src/lib/recipe-promotion.ts and is shared by the REST route and the MCP tool, so the ownership check, the idempotency rule and the generated description cannot drift apart. Adding to drinks navigates straight to the rating page: being able to rate it is the entire point, and an unrated cocktail is easy to lose in a list of 88. There is no Toaster mounted in this app, so the wishlist path reports inline instead. One MCP tool with a target parameter rather than two, since the tool list is already at the point where selection quality suffers - 23 now. Gated on drinks:write, not bar:write: it changes the collection, not the bar. Verified: promote to drink then rate it, idempotency on both targets, the recipe stays linked and undeleted, invalid target, unknown recipe, unauthenticated access, read-only tokens, and that another user's recipe is refused identically on both the REST route and the tool without confirming the row exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
264 lines
8.0 KiB
TypeScript
264 lines
8.0 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import Link from "next/link"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Check,
|
|
X,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Bookmark,
|
|
Trash2,
|
|
GlassWater,
|
|
Loader2,
|
|
Star,
|
|
Clock,
|
|
} from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import type { RecipeIngredient } from "@/hooks/use-bartender"
|
|
|
|
export interface RecipeCardData {
|
|
id?: string
|
|
title: string
|
|
ingredients: RecipeIngredient[]
|
|
steps: string[]
|
|
garnish?: string | null
|
|
glassware?: string | null
|
|
notes?: string | null
|
|
missingCount?: number
|
|
sourceDrink?: { id: string; name: string; type: string } | null
|
|
}
|
|
|
|
export type PromoteTarget = "drink" | "wishlist"
|
|
|
|
interface RecipeCardProps {
|
|
recipe: RecipeCardData
|
|
onSave?: (recipe: RecipeCardData) => void
|
|
onDelete?: (id: string) => void
|
|
onPromote?: (id: string, target: PromoteTarget) => void
|
|
isSaving?: boolean
|
|
isDeleting?: boolean
|
|
/** Which promotion is in flight for this card, if any. */
|
|
promoting?: PromoteTarget | null
|
|
saved?: boolean
|
|
}
|
|
|
|
export function RecipeCard({
|
|
recipe,
|
|
onSave,
|
|
onDelete,
|
|
onPromote,
|
|
isSaving,
|
|
isDeleting,
|
|
promoting,
|
|
saved,
|
|
}: RecipeCardProps) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
const availableCount = recipe.ingredients.filter((i) => i.available).length
|
|
const totalCount = recipe.ingredients.length
|
|
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<CardTitle className="text-lg leading-tight">
|
|
{recipe.title}
|
|
</CardTitle>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{recipe.missingCount !== undefined && (
|
|
<Badge
|
|
variant={recipe.missingCount === 0 ? "default" : "secondary"}
|
|
className={cn(
|
|
"text-xs",
|
|
recipe.missingCount === 0 &&
|
|
"bg-green-500/15 text-green-700 border-green-500/25"
|
|
)}
|
|
>
|
|
{recipe.missingCount === 0
|
|
? "Ready"
|
|
: `Missing ${recipe.missingCount}`}
|
|
</Badge>
|
|
)}
|
|
{!saved && onSave && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onSave(recipe)}
|
|
disabled={isSaving}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
{isSaving ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Bookmark className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{saved && recipe.id && onDelete && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onDelete(recipe.id!)}
|
|
disabled={isDeleting}
|
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
{isDeleting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{recipe.glassware && (
|
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<GlassWater className="h-3.5 w-3.5" />
|
|
{recipe.glassware}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
{/* Ingredients */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-medium">
|
|
Ingredients
|
|
</h4>
|
|
<span className="text-xs text-muted-foreground">
|
|
{availableCount}/{totalCount} available
|
|
</span>
|
|
</div>
|
|
<ul className="space-y-1">
|
|
{recipe.ingredients.map((ing, i) => (
|
|
<li
|
|
key={i}
|
|
className={cn(
|
|
"flex items-center gap-2 text-sm",
|
|
!ing.available && "text-muted-foreground"
|
|
)}
|
|
>
|
|
{ing.available ? (
|
|
<Check className="h-3.5 w-3.5 text-green-600 shrink-0" />
|
|
) : (
|
|
<X className="h-3.5 w-3.5 text-destructive shrink-0" />
|
|
)}
|
|
<span>
|
|
{ing.amount} {ing.name}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Garnish */}
|
|
{recipe.garnish && (
|
|
<p className="text-sm text-muted-foreground">
|
|
<span className="font-medium text-foreground">Garnish:</span>{" "}
|
|
{recipe.garnish}
|
|
</p>
|
|
)}
|
|
|
|
{/* Expandable Steps */}
|
|
<div>
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
|
>
|
|
{expanded ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
{expanded ? "Hide steps" : "Show steps"}
|
|
</button>
|
|
|
|
{expanded && (
|
|
<ol className="mt-2 space-y-2">
|
|
{recipe.steps.map((step, i) => (
|
|
<li key={i} className="flex gap-2 text-sm">
|
|
<span className="shrink-0 flex items-center justify-center h-5 w-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
|
{i + 1}
|
|
</span>
|
|
<span>{step}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
{expanded && recipe.notes && (
|
|
<div className="rounded-md bg-muted/50 p-3">
|
|
<p className="text-sm text-muted-foreground">{recipe.notes}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/*
|
|
A recipe is instructions; a drink is the thing you have an opinion
|
|
about. Promoting links the two rather than converting, so the recipe
|
|
stays here and also shows on the drink's page.
|
|
*/}
|
|
{saved && recipe.id && onPromote && (
|
|
<div className="flex flex-wrap gap-2 border-t pt-3">
|
|
{recipe.sourceDrink ? (
|
|
<Button asChild variant="secondary" size="sm">
|
|
<Link href={`/rate/${recipe.sourceDrink.id}`}>
|
|
<Star className="mr-1.5 h-4 w-4" />
|
|
Rate this
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={!!promoting}
|
|
onClick={() => onPromote(recipe.id!, "drink")}
|
|
>
|
|
{promoting === "drink" ? (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<GlassWater className="mr-1.5 h-4 w-4" />
|
|
)}
|
|
Add to my drinks
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!!promoting}
|
|
onClick={() => onPromote(recipe.id!, "wishlist")}
|
|
>
|
|
{promoting === "wishlist" ? (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Clock className="mr-1.5 h-4 w-4" />
|
|
)}
|
|
Try later
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{recipe.sourceDrink && (
|
|
<p className="text-xs text-muted-foreground">
|
|
In your drinks as{" "}
|
|
<Link
|
|
href={`/drinks/${recipe.sourceDrink.id}`}
|
|
className="text-primary underline-offset-4 hover:underline"
|
|
>
|
|
{recipe.sourceDrink.name}
|
|
</Link>
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|