Public Access
feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)
User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.
Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
(family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.
Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
(overlay on RecipeCard) and detail (text buttons in RecipeDetail
top bar). Popover with Allergy (red, window.confirm) + Dislike
(neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
B12 pattern, 6s window). Pre-existing block detection shows a
Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
+ e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
of Add to Plan.
Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).
Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
This commit is contained in:
@@ -73,6 +73,20 @@ export const mealPlannerApi = {
|
||||
api.post('/pantry/bulk', { items }),
|
||||
},
|
||||
|
||||
// Sprint 10: family-facing never-suggest endpoints. These are
|
||||
// separate from the existing /api/admin/never-suggest admin path
|
||||
// (which still requires the ADMIN_TOKEN). The new public path
|
||||
// uses require_session (auto-resolves to the first family profile
|
||||
// on the trusted network) and enforces row-level ownership on DELETE
|
||||
// (403 if the row belongs to a different family).
|
||||
neverSuggest: {
|
||||
list: (familyProfileId: string) =>
|
||||
api.get('/never-suggest', { params: { family_profile_id: familyProfileId } }),
|
||||
add: (data: { family_profile_id: string; recipe_id: string; reason: 'allergy' | 'dislike'; notes?: string }) =>
|
||||
api.post('/never-suggest', data),
|
||||
remove: (nsId: string) => api.delete(`/never-suggest/${nsId}`),
|
||||
},
|
||||
|
||||
shoppingList: {
|
||||
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
|
||||
getPrint: () => api.get('/shopping-list/print'),
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* "Never suggest" button — used on Recipes cards + RecipeDetail page.
|
||||
*
|
||||
* Two reasons (matching the NeverSuggestReason enum on the server):
|
||||
* - "allergy" → red, requires window.confirm (irreversible to the user)
|
||||
* - "dislike" → neutral, no confirm (undo toast is the escape hatch)
|
||||
*
|
||||
* On success:
|
||||
* - Optimistically removes the recipe from the list (via queryKey
|
||||
* invalidation in the parent).
|
||||
* - Pops a Sprint-3-style undo toast. Clicking Undo calls
|
||||
* DELETE /api/never-suggest/{id} and re-invalidates the queries.
|
||||
*
|
||||
* The button is `position: absolute` on the card overlay. On the
|
||||
* detail page, the parent passes `variant="detail"` to render it as
|
||||
* a row of two text buttons in the top bar.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Ban, X, AlertTriangle, ThumbsDown } from 'lucide-react'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import { showToast, showApiError } from '../lib/toast'
|
||||
|
||||
interface NeverSuggestButtonProps {
|
||||
recipeId: string
|
||||
recipeName: string
|
||||
/** "card" overlays the recipe image; "detail" renders inline as a button group. */
|
||||
variant: 'card' | 'detail'
|
||||
}
|
||||
|
||||
interface NeverSuggestRow {
|
||||
id: string
|
||||
recipe_id: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export function NeverSuggestButton({ recipeId, recipeName, variant }: NeverSuggestButtonProps) {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [familyId, setFamilyId] = useState<string | null>(null)
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
// Pull family id from the profile query (same pattern as Recommended.tsx).
|
||||
// Doing it inline (rather than via a context) keeps the dependency
|
||||
// surface flat and matches the rest of the codebase.
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => mealPlannerApi.profile.get().then(r => r.data),
|
||||
})
|
||||
useEffect(() => {
|
||||
if (profile?.id && familyId !== profile.id) setFamilyId(profile.id)
|
||||
}, [profile, familyId])
|
||||
|
||||
// Find the existing NeverSuggest row for this recipe (if any) so the
|
||||
// button can show a "Remove block" state. Without this, re-clicking
|
||||
// the button on a recipe that's already blocked would 201 (idempotent
|
||||
// on the server) but the toast would say "added" which is confusing.
|
||||
const { data: existing } = useQuery<NeverSuggestRow[]>({
|
||||
queryKey: ['neverSuggest', familyId],
|
||||
queryFn: () =>
|
||||
familyId
|
||||
? mealPlannerApi.neverSuggest.list(familyId).then(r => r.data)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!familyId,
|
||||
})
|
||||
const existingRow = existing?.find(r => r.recipe_id === recipeId)
|
||||
|
||||
const invalidateAll = () => {
|
||||
qc.invalidateQueries({ queryKey: ['neverSuggest', familyId] })
|
||||
qc.invalidateQueries({ queryKey: ['recipes'] })
|
||||
qc.invalidateQueries({ queryKey: ['recommendedRecipes', familyId] })
|
||||
qc.invalidateQueries({ queryKey: ['mealPlan'] })
|
||||
}
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (reason: 'allergy' | 'dislike') => {
|
||||
if (!familyId) throw new Error('family not loaded')
|
||||
return mealPlannerApi.neverSuggest
|
||||
.add({ family_profile_id: familyId, recipe_id: recipeId, reason })
|
||||
.then(r => r.data)
|
||||
},
|
||||
onSuccess: (data, reason) => {
|
||||
invalidateAll()
|
||||
setOpen(false)
|
||||
const reasonLabel = reason === 'allergy' ? 'allergy' : "won't suggest"
|
||||
showToast.undo(
|
||||
`Marked ${recipeName} as ${reasonLabel} for your family`,
|
||||
() => {
|
||||
// Best-effort undo. The toast dismisses itself on success.
|
||||
if (data?.id) {
|
||||
mealPlannerApi.neverSuggest
|
||||
.remove(data.id)
|
||||
.then(() => invalidateAll())
|
||||
.catch(() => {
|
||||
// The toast is already gone; show a fresh error toast.
|
||||
showToast.error('Could not undo — open NeverSuggest API to remove manually.')
|
||||
})
|
||||
}
|
||||
},
|
||||
6000,
|
||||
)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// Pull the FastAPI detail string for the toast (Sprint 4 F7 helper).
|
||||
showApiError(err, 'Could not mark recipe')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (nsId: string) => mealPlannerApi.neverSuggest.remove(nsId),
|
||||
onSuccess: () => {
|
||||
invalidateAll()
|
||||
showToast.success(`Removed block on ${recipeName}`)
|
||||
},
|
||||
})
|
||||
|
||||
// Close popover on outside click + Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node
|
||||
if (popoverRef.current?.contains(t)) return
|
||||
if (buttonRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onPickReason = (reason: 'allergy' | 'dislike') => {
|
||||
if (reason === 'allergy') {
|
||||
const ok = window.confirm(
|
||||
`Mark "${recipeName}" as an allergy for your family?\n\n` +
|
||||
`This permanently blocks the recipe. The planner will avoid it in all future plans. ` +
|
||||
`You can undo this from the toast that appears, but it will not appear in any subsequent meal plan.`,
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
addMutation.mutate(reason)
|
||||
}
|
||||
|
||||
const onRemoveExisting = () => {
|
||||
if (!existingRow) return
|
||||
if (!window.confirm(`Stop blocking "${recipeName}"? The planner may suggest it again.`)) return
|
||||
removeMutation.mutate(existingRow.id)
|
||||
}
|
||||
|
||||
// ----- Render -----
|
||||
|
||||
if (existingRow) {
|
||||
// Recipe is already blocked — show an "unblock" affordance.
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemoveExisting() }}
|
||||
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
|
||||
className="absolute top-2 right-2 z-10 inline-flex items-center justify-center w-8 h-8 rounded-full bg-danger-100 text-danger-700 hover:bg-danger-200 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm"
|
||||
title="Recipe is blocked — click to unblock"
|
||||
>
|
||||
<Ban className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onRemoveExisting}
|
||||
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-danger-300 text-danger-700 hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-danger-400 inline-flex items-center gap-1.5"
|
||||
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Unblock
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(o => !o) }}
|
||||
aria-label={`Never suggest ${recipeName} again`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-white/90 backdrop-blur-sm text-surface-700 hover:bg-white hover:text-danger-600 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
title="Never suggest this recipe"
|
||||
>
|
||||
<Ban className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
aria-label="Choose a reason"
|
||||
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 animate-fade-in"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Never suggest this recipe</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onPickReason('allergy')}
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Allergy</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onPickReason('dislike')}
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Dislike</span>
|
||||
</button>
|
||||
<div className="border-t border-surface-100 mt-1 pt-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// detail variant — text buttons in a row
|
||||
return (
|
||||
<div className="relative inline-flex items-center gap-1.5">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Never suggest ${recipeName} again`}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Deny forever
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
aria-label="Choose a reason"
|
||||
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 z-20 animate-fade-in"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Mark as</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onPickReason('allergy')}
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Allergy</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onPickReason('dislike')}
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Dislike</span>
|
||||
</button>
|
||||
<div className="border-t border-surface-100 mt-1 pt-1 px-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Badge } from '../components/ui/Badge'
|
||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { NeverSuggestButton } from '../components/NeverSuggestButton'
|
||||
|
||||
function RecipeDetailSkeleton() {
|
||||
return (
|
||||
@@ -69,9 +70,16 @@ export default function RecipeDetail() {
|
||||
<h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1>
|
||||
<p className="text-sm text-surface-500 truncate">{recipe.description}</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
|
||||
Add to Plan
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<NeverSuggestButton
|
||||
recipeId={recipe.id}
|
||||
recipeName={recipe.name}
|
||||
variant="detail"
|
||||
/>
|
||||
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
|
||||
Add to Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { Select } from '../components/ui/Select'
|
||||
import { NeverSuggestButton } from '../components/NeverSuggestButton'
|
||||
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
|
||||
|
||||
const CUISINE_OPTIONS = [
|
||||
@@ -242,7 +243,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
||||
return (
|
||||
<Link to={`/recipes/${recipe.id}`} className="group block">
|
||||
<Card className="h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
|
||||
<Card className="relative h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
|
||||
{recipe.image_url ? (
|
||||
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
|
||||
<img
|
||||
@@ -250,10 +251,20 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
alt={recipe.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<NeverSuggestButton
|
||||
recipeId={recipe.id}
|
||||
recipeName={recipe.name}
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center">
|
||||
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center relative">
|
||||
<CookingPot className="w-12 h-12 text-surface-300" />
|
||||
<NeverSuggestButton
|
||||
recipeId={recipe.id}
|
||||
recipeName={recipe.name}
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardBody className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user