Public Access
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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: '/' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user