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
+4 -1
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ErrorBoundary } from './components/ErrorBoundary'
import Dashboard from './pages/Dashboard'
@@ -8,6 +8,7 @@ import Recipes from './pages/Recipes'
import Recommended from './pages/Recommended'
import RecipeDetail from './pages/RecipeDetail'
import ShoppingList from './pages/ShoppingList'
import NotFound from './pages/NotFound'
const queryClient = new QueryClient()
@@ -50,9 +51,11 @@ function App() {
<Route path="/meals/:id" element={<MealDetail />} />
<Route path="/recipes" element={<Recipes />} />
<Route path="/recipes/recommended" element={<Recommended />} />
<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />
<Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/pantry" element={<Pantry />} />
<Route path="/shopping-list" element={<ShoppingList />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
+10 -4
View File
@@ -1,4 +1,5 @@
import { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from './Button';
interface EmptyStateProps {
@@ -7,7 +8,8 @@ interface EmptyStateProps {
description: string;
action?: {
label: string;
onClick: () => void;
onClick?: () => void;
to?: string;
};
}
@@ -20,9 +22,13 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
<h3 className="text-lg font-semibold text-surface-900 mb-1">{title}</h3>
<p className="text-sm text-surface-500 max-w-sm mb-6">{description}</p>
{action && (
<Button onClick={action.onClick}>
{action.label}
</Button>
action.to ? (
<Link to={action.to}>
<Button>{action.label}</Button>
</Link>
) : (
<Button onClick={action.onClick}>{action.label}</Button>
)
)}
</div>
);
+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}
/>
))}
+5 -4
View File
@@ -188,7 +188,9 @@ export default function MealDetail() {
</div>
<div className="text-left sm:text-right flex-shrink-0">
<div className="text-xl sm:text-2xl font-bold text-white">
${item.estimated_cost?.toFixed(2) || 'N/A'}
{item.estimated_cost != null
? `$${item.estimated_cost.toFixed(2)}`
: 'No estimate'}
</div>
<div className="text-xs sm:text-sm text-white/70">per serving</div>
</div>
@@ -246,10 +248,9 @@ export default function MealDetail() {
<div className="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0" />
<span className="text-sm text-surface-700">
<span className="font-medium">
{ing.quantity && `${ing.quantity} `}
{ing.unit && `${ing.unit} `}
{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''} `}
</span>
{ing.name}
{ing.name || ing.ingredient?.name || 'Ingredient'}
{ing.is_optional && (
<span className="text-surface-400 ml-1">(optional)</span>
)}
+13
View File
@@ -0,0 +1,13 @@
import { Compass } from 'lucide-react';
import { EmptyState } from '../components/ui/EmptyState';
export default function NotFound() {
return (
<EmptyState
icon={Compass}
title="We can't find that page"
description="The page you were looking for doesn't exist or has been moved."
action={{ label: 'Back to dashboard', to: '/' }}
/>
);
}
+1 -1
View File
@@ -158,7 +158,7 @@ export default function RecipeDetail() {
<li key={i} className="flex items-start gap-2 text-sm text-surface-700">
<span className="w-2 h-2 rounded-full bg-primary-400 mt-1.5 flex-shrink-0" />
<span>
{ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''} `}
{ing.name || 'Unknown ingredient'}
</span>
</li>
+2
View File
@@ -58,6 +58,8 @@ export interface RecipeIngredient {
quantity?: number
unit?: string
is_optional: boolean
notes?: string | null
ingredient?: { name?: string; aisle?: string }
}
export interface MealPlan {