fix(ui): close 5 P0 audit findings (ingredients, cost, routing, mobile slots)

Sprint 1 of the UI/UX audit (Review/ui-nielsen-audit.md).

- RecipeDetail: drop .trim() on ingredient line so unit and name no longer fuse
  ('2 canBlack Beans' -> '2 can Black Beans').
- MealDetail: align ingredient field name to backend ('qty' not 'quantity'),
  add 'ingredient.name' fallback for the missing nested name from API.
- MealDetail: '$N/A per serving' -> '$X.XX' or 'No estimate'.
- App: add /recommended alias to /recipes/recommended, plus a catch-all
  NotFound page so unrecognised URLs no longer render blank.
- Dashboard: remove 'hidden md:*' on empty meal slots so mobile users can
  tap Generate. Bump empty-slot button to 44px min-height (a11y).
- EmptyState: accept an optional 'to' prop for Link-wrapped actions.
- types: extend RecipeIngredient with optional notes and nested ingredient.
This commit is contained in:
2026-06-02 10:55:53 -07:00
parent c364b8b222
commit f3e4a446a3
7 changed files with 73 additions and 41 deletions
+38 -31
View File
@@ -3,7 +3,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
GripVertical
GripVertical, X
} from 'lucide-react'
import toast from 'react-hot-toast'
import {
@@ -31,12 +31,13 @@ const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
/* ------------------------------------------------------------------ */
/* MealCard (draggable) */
/* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny: _onDeny, onDelete }: {
item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
}) {
const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
@@ -48,13 +49,18 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
'neutral'
return (
<div
className={`
group block bg-surface-0 rounded-xl border overflow-hidden
hover:shadow-md hover:border-primary-200 transition-all duration-200
${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}
`}
>
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
{/* Keep delete visible on touch devices where hover is unavailable. */}
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.id) }}
className="absolute top-1 right-1 z-10 rounded-full bg-danger-100 p-1 text-danger-600 shadow-sm transition-colors hover:bg-danger-200 hover:text-danger-800 focus:outline-none focus:ring-2 focus:ring-danger-400 focus:ring-offset-1"
aria-label="Delete meal"
title="Delete meal"
>
<X className="w-4 h-4" />
</button>
)}
<div className="flex gap-2 p-2">
{/* drag handle */}
<div
@@ -76,7 +82,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
<CookingPot className="w-6 h-6 text-surface-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 pr-7">
<Link to={`/meals/${item.id}`} className="block">
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'}
@@ -96,23 +102,6 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
</span>
)}
</div>
{/* Approve / Deny buttons */}
{item.approval_status === 'pending' && onApprove && onDeny && (
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={(e) => { e.stopPropagation(); onApprove(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-success-50 text-success-700 hover:bg-success-100 font-medium transition-colors"
>
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeny(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-danger-50 text-danger-700 hover:bg-danger-100 font-medium transition-colors"
>
Deny
</button>
</div>
)}
</div>
</div>
</div>
@@ -128,6 +117,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item,
onApprove,
onDeny,
onDelete,
onGenerate,
}: {
dayIndex: number
@@ -135,6 +125,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item?: MealPlanItem
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const droppableId = `slot-${dayIndex}-${mealType}`
@@ -164,17 +155,18 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
isDragging={dragSnapshot.isDragging}
onApprove={onApprove}
onDeny={onDeny}
onDelete={onDelete}
/>
</div>
)}
</Draggable>
) : (
<div className="hidden md:flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1">
<div className="flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1">
<span className="text-xs text-surface-300">Empty</span>
{onGenerate && (
<button
onClick={() => onGenerate(dayIndex, mealType)}
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors"
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors min-h-11"
>
Generate
</button>
@@ -196,12 +188,14 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
items,
onApprove,
onDeny,
onDelete,
onGenerate,
}: {
dayIndex: number
items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const today = new Date().getDay()
@@ -222,7 +216,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType} className={item ? '' : 'hidden md:block'}>
<div key={mealType}>
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType}
</span>
@@ -232,6 +226,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item={item}
onApprove={onApprove}
onDeny={onDeny}
onDelete={onDelete}
onGenerate={onGenerate}
/>
</div>
@@ -326,7 +321,7 @@ export default function Dashboard() {
const { draggableId, destination } = result
const itemId = draggableId.replace('item-', '')
const [, dayStr, typeStr] = destination.droppableId.split('-')
handleDrop(itemId, parseInt(dayStr, 10), typeStr)
handleDrop(itemId, parseInt(dayStr, 10) + 1, typeStr)
}
async function handleApprove(itemId: string) {
@@ -349,6 +344,17 @@ export default function Dashboard() {
}
}
async function handleDelete(itemId: string) {
if (!confirm('Delete this meal from the plan?')) return
try {
await mealPlannerApi.meals.deleteItem(itemId)
toast.success('Meal deleted')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to delete meal')
}
}
async function handleGenerate(dayIndex: number, mealType: string) {
if (!mealPlan) return
try {
@@ -436,6 +442,7 @@ export default function Dashboard() {
items={items}
onApprove={handleApprove}
onDeny={handleDeny}
onDelete={handleDelete}
onGenerate={handleGenerate}
/>
))}