Public Access
feat(meals): suggest complementary sides
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
type DroppableStateSnapshot,
|
||||
} from '@hello-pangea/dnd'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { MealPlan, MealPlanItem } from '../types'
|
||||
import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
||||
@@ -33,6 +33,18 @@ 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
|
||||
|
||||
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
|
||||
const value = item.components?.suggested_sides
|
||||
if (!value || typeof value !== 'object') return null
|
||||
return value as SuggestedSides
|
||||
}
|
||||
|
||||
function formatSuggestedSides(sides: SuggestedSides): string | null {
|
||||
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}`
|
||||
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' + ')
|
||||
return pair ? `Add ${pair}` : null
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealCard (draggable) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -55,6 +67,8 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
||||
item.approval_status === 'denied' ? 'danger' :
|
||||
item.approval_status === 'swapped' ? 'warning' :
|
||||
'neutral'
|
||||
const suggestedSides = getSuggestedSides(item)
|
||||
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
|
||||
|
||||
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'}`}>
|
||||
@@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
||||
{totalTime > 0 && `${totalTime} min · `}
|
||||
{item.recipe?.servings} servings
|
||||
</p>
|
||||
{sideText && (
|
||||
<p className="mt-1 rounded-lg bg-primary-50 px-2 py-1 text-[11px] leading-snug text-primary-800">
|
||||
{sideText}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge
|
||||
variant={statusVariant}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { MealPlanItem, Feedback } from '../types'
|
||||
import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||
@@ -22,6 +22,18 @@ const DENIAL_REASONS = [
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
|
||||
const value = item.components?.suggested_sides
|
||||
if (!value || typeof value !== 'object') return null
|
||||
return value as SuggestedSides
|
||||
}
|
||||
|
||||
function formatSuggestedSides(sides: SuggestedSides): string | null {
|
||||
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}.`
|
||||
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' and ')
|
||||
return pair ? `Add ${pair}.` : null
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
@@ -158,6 +170,8 @@ export default function MealDetail() {
|
||||
|
||||
const totalTime = recipe.total_time_minutes ??
|
||||
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
||||
const suggestedSides = getSuggestedSides(item)
|
||||
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
@@ -240,6 +254,20 @@ export default function MealDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sideText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-surface-900">Complete the meal</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p className="text-sm text-surface-700">{sideText}</p>
|
||||
{suggestedSides?.note && (
|
||||
<p className="mt-2 text-xs text-surface-500">{suggestedSides.note}</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -62,6 +62,21 @@ export interface Recipe {
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
total_time_minutes?: number
|
||||
side_dishes?: SideDish[]
|
||||
}
|
||||
|
||||
export interface SuggestedSides {
|
||||
needed?: boolean
|
||||
vegetable?: string
|
||||
carb?: string
|
||||
items?: string[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface SideDish {
|
||||
name: string
|
||||
ingredients?: Array<{ name: string; qty: number; unit?: string }>
|
||||
prep_notes?: string
|
||||
}
|
||||
|
||||
export interface RecipeIngredient {
|
||||
@@ -117,6 +132,8 @@ export interface MealPlanItem {
|
||||
approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
|
||||
denial_reason?: string
|
||||
denial_details?: string
|
||||
score?: number | null
|
||||
components?: Record<string, unknown> | null
|
||||
estimated_cost?: number
|
||||
used_pantry_items: string[]
|
||||
recipe?: Recipe
|
||||
|
||||
Reference in New Issue
Block a user