Public Access
F7: surface every failed query/mutation as a toast via react-query
QueryCache/MutationCache onError, with a single error normalizer that
extracts FastAPI's response.data.detail (string or Pydantic 422 array).
- lib/toast.tsx: new extractErrorMessage(err, fallback) and
showApiError(err, fallback). Reads response.data.detail when present
(string or [{loc, msg, type}, ...] array), then err.message, then
the fallback. No more '[object Object]' or raw stack traces.
- App.tsx: QueryClient is now created with QueryCache and
MutationCache onError handlers wired to showApiError. Added
defaultOptions.queries: { retry: 1, refetchOnWindowFocus: false }
so background refetch failures are no longer silent (the audit's
H9 finding).
- Dashboard.tsx: removed 6 local try/catch toasts (move/approve/deny/
delete/generate) since the global handler now covers them. Kept
VoteEmailButton.handleSend and handleDelete's undo-callback with
showApiError(err, 'Failed to ...') for action-specific fallback
strings — those are user-initiated recovery paths where a contextual
default is more useful than the bare FastAPI detail.
- Pantry.tsx: removed 3 local onError handlers (addMutation,
removeMutation, handleAdd's createIngredient path) and
handleRemove's outer catch. Kept 3 pre-flight client-side checks
(missing ingredient link, empty name, unresolved ingredient) that
never reach the network. handleRemove's undo callback now uses
showApiError for the restore failure.
- MealDetail.tsx: removed submitMutation.onError. The local
'Failed to save feedback. Please try again.' string is replaced
by the actual FastAPI detail (e.g. 'Feedback for this meal already
exists' or the Pydantic 422 msg).
Net result: 10 backend-error try/catch blocks deleted, error messages
are now identical to what the backend actually says, and any future
mutation that forgets to add a local onError still gets surfaced.
F6: Dashboard plan-status Badge (variant driven by status: draft /
awaiting_approval / approved / rejected) now passes an explicit
aria-label='Plan status: <text>' so a screen reader announces both
the category and the value instead of just the colour-encoded text.
This matches the pattern already used for the per-item approval
status Badge in Dashboard.tsx (added in Sprint 3) and completes the
audit §Sprint 3 a11y sweep for that page.
build: tsc 0 errors, vite 0 errors. 5 files, +72/-19.
528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
|
|
GripVertical, X
|
|
} from 'lucide-react'
|
|
import toast from 'react-hot-toast'
|
|
import { showToast, showApiError } from '../lib/toast'
|
|
import {
|
|
DragDropContext,
|
|
Droppable,
|
|
Draggable,
|
|
type DropResult,
|
|
type DroppableProvided,
|
|
type DraggableProvided,
|
|
type DraggableProvidedDragHandleProps,
|
|
type DraggableStateSnapshot,
|
|
type DroppableStateSnapshot,
|
|
} from '@hello-pangea/dnd'
|
|
import { mealPlannerApi } from '../api'
|
|
import type { MealPlan, MealPlanItem } from '../types'
|
|
import { Badge } from '../components/ui/Badge'
|
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
|
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
|
import { EmptyState } from '../components/ui/EmptyState'
|
|
|
|
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* MealCard (draggable) */
|
|
/* ------------------------------------------------------------------ */
|
|
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)
|
|
|
|
const statusVariant =
|
|
item.approval_status === 'approved' ? 'success' :
|
|
item.approval_status === 'denied' ? 'danger' :
|
|
item.approval_status === 'swapped' ? 'warning' :
|
|
'neutral'
|
|
|
|
return (
|
|
<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
|
|
{...dragHandleProps}
|
|
className="flex-shrink-0 self-center cursor-grab active:cursor-grabbing text-surface-400 hover:text-surface-600"
|
|
aria-label="Drag to move meal"
|
|
>
|
|
<GripVertical className="w-4 h-4" />
|
|
</div>
|
|
|
|
{item.recipe?.image_url ? (
|
|
<img
|
|
src={item.recipe.image_url}
|
|
alt={item.recipe.name}
|
|
className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
|
<CookingPot className="w-5 h-5 md:w-6 md:h-6 text-surface-400" />
|
|
</div>
|
|
)}
|
|
<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 leading-tight line-clamp-2 group-hover:text-primary-700 transition-colors">
|
|
{item.recipe?.name || 'Unknown Recipe'}
|
|
</h4>
|
|
</Link>
|
|
<p className="text-[11px] text-surface-500 mt-0.5">
|
|
{totalTime > 0 && `${totalTime} min · `}
|
|
{item.recipe?.servings} servings
|
|
</p>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
<Badge
|
|
variant={statusVariant}
|
|
className="text-[10px] px-1.5 py-0.5"
|
|
aria-label={`Approval status: ${item.approval_status}`}
|
|
>
|
|
{item.approval_status}
|
|
</Badge>
|
|
{item.estimated_cost && (
|
|
<span className="text-[11px] font-medium text-surface-600">
|
|
${item.estimated_cost.toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* MealSlot — droppable area for one day + meal_type */
|
|
/* ------------------------------------------------------------------ */
|
|
function MealSlot({
|
|
dayIndex,
|
|
mealType,
|
|
item,
|
|
onApprove,
|
|
onDeny,
|
|
onDelete,
|
|
onGenerate,
|
|
}: {
|
|
dayIndex: number
|
|
mealType: string
|
|
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}`
|
|
|
|
return (
|
|
<Droppable droppableId={droppableId}>
|
|
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
className={`
|
|
rounded-lg p-1 min-h-[20px] md:min-h-[80px] transition-colors
|
|
${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'}
|
|
`}
|
|
>
|
|
{item ? (
|
|
<Draggable draggableId={`item-${item.id}`} index={0}>
|
|
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
|
<div
|
|
ref={dragProvided.innerRef}
|
|
{...dragProvided.draggableProps}
|
|
style={dragProvided.draggableProps.style}
|
|
>
|
|
<MealCard
|
|
item={item}
|
|
dragHandleProps={dragProvided.dragHandleProps}
|
|
isDragging={dragSnapshot.isDragging}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
onDelete={onDelete}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
) : (
|
|
<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 min-h-11"
|
|
>
|
|
Generate
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* DayColumn */
|
|
/* ------------------------------------------------------------------ */
|
|
function DayColumn({
|
|
dayIndex,
|
|
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()
|
|
const isToday = today === (dayIndex + 1) % 7
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div className={`px-2 py-1.5 rounded-lg ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
|
{FULL_DAY_NAMES[dayIndex]}
|
|
</span>
|
|
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
|
|
</div>
|
|
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{MEAL_TYPES.map(mealType => {
|
|
const item = items.find(i => i.meal_type === mealType)
|
|
return (
|
|
<div key={mealType}>
|
|
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
|
|
{mealType}
|
|
</span>
|
|
<MealSlot
|
|
dayIndex={dayIndex}
|
|
mealType={mealType}
|
|
item={item}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
onDelete={onDelete}
|
|
onGenerate={onGenerate}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="flex justify-between items-center">
|
|
<Skeleton className="h-8 w-48" />
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-7 w-24" />
|
|
<Skeleton className="h-7 w-20" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="h-8 w-full" />
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* VoteEmailButton */
|
|
/* ------------------------------------------------------------------ */
|
|
function VoteEmailButton() {
|
|
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
|
|
|
|
async function handleSend() {
|
|
setSendingVoteEmail(true)
|
|
try {
|
|
const res = await mealPlannerApi.admin.triggerOrchestrate('email')
|
|
const status = res.data?.status || res.data?.message || 'sent'
|
|
toast.success(status)
|
|
} catch (err) {
|
|
showApiError(err, 'Failed to send vote emails')
|
|
} finally {
|
|
setSendingVoteEmail(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={sendingVoteEmail}
|
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{sendingVoteEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronRight className="w-4 h-4" />}
|
|
{sendingVoteEmail ? 'Sending...' : 'Send Vote Email'}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Dashboard */
|
|
/* ------------------------------------------------------------------ */
|
|
export default function Dashboard() {
|
|
const queryClient = useQueryClient()
|
|
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
|
queryKey: ['mealPlan'],
|
|
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
|
})
|
|
|
|
async function handleDrop(itemId: string, newDay: number, newType: string) {
|
|
try {
|
|
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
|
|
toast.success('Meal moved')
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
function onDragEnd(result: DropResult) {
|
|
if (!result.destination) return
|
|
const { draggableId, destination } = result
|
|
const itemId = draggableId.replace('item-', '')
|
|
const [, dayStr, typeStr] = destination.droppableId.split('-')
|
|
handleDrop(itemId, parseInt(dayStr, 10) + 1, typeStr)
|
|
}
|
|
|
|
async function handleApprove(itemId: string) {
|
|
try {
|
|
await mealPlannerApi.meals.approveItem(itemId)
|
|
toast.success('Meal approved')
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
async function handleDeny(itemId: string) {
|
|
try {
|
|
await mealPlannerApi.meals.denyItem(itemId)
|
|
toast.success('Meal denied')
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
async function handleDelete(itemId: string) {
|
|
if (!mealPlan) return
|
|
const item = mealPlan.items.find(i => i.id === itemId)
|
|
if (!item) return
|
|
try {
|
|
await mealPlannerApi.meals.deleteItem(itemId)
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
showToast.undo(
|
|
'Meal deleted',
|
|
async () => {
|
|
try {
|
|
await mealPlannerApi.meals.generateItem(
|
|
mealPlan.id,
|
|
item.day_of_week,
|
|
item.meal_type
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
toast.success('Slot filled with a new meal')
|
|
} catch (err) {
|
|
showApiError(err, 'Failed to refill slot')
|
|
}
|
|
}
|
|
)
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
async function handleGenerate(dayIndex: number, mealType: string) {
|
|
if (!mealPlan) return
|
|
try {
|
|
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
|
|
toast.success('Meal generated')
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
if (isLoading) return <DashboardSkeleton />
|
|
|
|
if (!mealPlan) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<CalendarDays className="w-6 h-6 text-primary-600" />
|
|
<h1 className="text-2xl font-bold text-surface-900">This Week's Meal Plan</h1>
|
|
</div>
|
|
<EmptyState
|
|
icon={Sparkles}
|
|
title="No meal plan yet"
|
|
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
|
|
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const itemsByDay = Array.from({ length: 7 }, (_, i) =>
|
|
mealPlan.items.filter(item => item.day_of_week === i + 1)
|
|
)
|
|
|
|
const statusVariant =
|
|
mealPlan.status === 'approved' ? 'success' :
|
|
mealPlan.status === 'locked' ? 'primary' :
|
|
mealPlan.status === 'pending_approval' ? 'warning' :
|
|
'neutral'
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
|
<CalendarDays className="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-surface-900">
|
|
Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</h1>
|
|
<p className="text-sm text-surface-500">
|
|
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Badge
|
|
variant={statusVariant}
|
|
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
|
|
>
|
|
{mealPlan.status.replace(/_/g, ' ')}
|
|
</Badge>
|
|
{mealPlan.total_estimated_cost !== undefined && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 rounded-lg">
|
|
<span className="text-sm font-semibold text-surface-900">
|
|
${mealPlan.total_estimated_cost.toFixed(2)}
|
|
</span>
|
|
<span className="text-xs text-surface-500">total</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Weekly Grid — all 7 days visible */}
|
|
<Card>
|
|
<CardHeader>
|
|
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
{itemsByDay.map((items, index) => (
|
|
<DayColumn
|
|
key={index}
|
|
dayIndex={index}
|
|
items={items}
|
|
onApprove={handleApprove}
|
|
onDeny={handleDeny}
|
|
onDelete={handleDelete}
|
|
onGenerate={handleGenerate}
|
|
/>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* Quick Actions */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardBody>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-xl bg-success-50 flex items-center justify-center flex-shrink-0">
|
|
<ShoppingCart className="w-5 h-5 text-success-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-surface-900">Shopping List</h3>
|
|
<p className="text-sm text-surface-500 mt-1">
|
|
View your aisle-organized shopping list with sale tracking.
|
|
</p>
|
|
<Link
|
|
to="/shopping-list"
|
|
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
|
>
|
|
View shopping list <ChevronRight className="w-4 h-4" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardBody>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center flex-shrink-0">
|
|
<Sparkles className="w-5 h-5 text-warning-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-surface-900">Quick Actions</h3>
|
|
<div className="flex flex-col gap-2 mt-2">
|
|
<Link
|
|
to="/pantry"
|
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
|
>
|
|
Manage Pantry <ChevronRight className="w-4 h-4" />
|
|
</Link>
|
|
<VoteEmailButton />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|