Public Access
feat: Phase 8 Feedback UI + API endpoints
- New backend/app/api/feedback.py: GET/POST for meal_plan_item feedback - MealDetail.tsx: star rating, never-suggest checkbox, reason dropdown, free-text comments, displays saved feedback - frontend/src/api/index.ts + types: feedback API + TypeScript interface - backend/app/schemas/__init__.py: model_validator maps qty→quantity for RecipeIngredient (fixes Pydantic validation on recipe JSONB) - docs/HANDOFF.md: mark Phase 8 complete, update file map and date
This commit is contained in:
@@ -75,6 +75,11 @@ export const mealPlannerApi = {
|
||||
getStats: () => api.get('/admin/stats'),
|
||||
testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }),
|
||||
},
|
||||
|
||||
feedback: {
|
||||
get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`),
|
||||
create: (data: any) => api.post('/feedback', data),
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,10 +1,39 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { MealPlanItem } from '../types'
|
||||
import type { MealPlanItem, Feedback } from '../types'
|
||||
|
||||
const DENIAL_REASONS = [
|
||||
{ value: '', label: 'Select a reason...' },
|
||||
{ value: 'too_expensive', label: 'Too expensive' },
|
||||
{ value: 'boring', label: 'Boring / not interesting' },
|
||||
{ value: 'disliked_ingredient', label: 'Disliked ingredient' },
|
||||
{ value: 'cultural', label: 'Cultural / dietary preference' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
className={`text-2xl ${n <= value ? 'text-yellow-400' : 'text-gray-300'} hover:text-yellow-400`}
|
||||
aria-label={`Rate ${n} stars`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MealDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: item, isLoading } = useQuery<MealPlanItem>({
|
||||
queryKey: ['mealItem', id],
|
||||
@@ -12,6 +41,26 @@ export default function MealDetail() {
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const { data: existingFeedback } = useQuery<Feedback | null>({
|
||||
queryKey: ['feedback', id],
|
||||
queryFn: () => mealPlannerApi.feedback.get(id!).then(r => r.data),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const [rating, setRating] = useState(0)
|
||||
const [neverSuggest, setNeverSuggest] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feedback', id] })
|
||||
setSubmitted(true)
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
@@ -30,6 +79,19 @@ export default function MealDetail() {
|
||||
}
|
||||
|
||||
const recipe = item.recipe
|
||||
const feedback = existingFeedback
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!id) return
|
||||
submitMutation.mutate({
|
||||
meal_plan_item_id: id,
|
||||
rating: rating || null,
|
||||
never_suggest: neverSuggest,
|
||||
denial_reason: reason || null,
|
||||
feedback_text: text || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -100,6 +162,103 @@ export default function MealDetail() {
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Feedback</h2>
|
||||
|
||||
{feedback?.rating ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Your rating:</span>
|
||||
<div className="flex text-yellow-400">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} className={n <= (feedback.rating || 0) ? 'text-yellow-400' : 'text-gray-300'}>★</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{feedback.never_suggest && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800">
|
||||
Never suggest this recipe again
|
||||
</div>
|
||||
)}
|
||||
{feedback.denial_reason && (
|
||||
<p className="text-sm text-gray-600 capitalize">
|
||||
Reason: {feedback.denial_reason.replace('_', ' ')}
|
||||
</p>
|
||||
)}
|
||||
{feedback.feedback_text && (
|
||||
<p className="text-sm text-gray-700 italic">"{feedback.feedback_text}"</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSubmitted(false)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Edit feedback
|
||||
</button>
|
||||
</div>
|
||||
) : submitted ? (
|
||||
<div className="text-green-700 bg-green-50 rounded-lg p-4">
|
||||
Thanks for your feedback!
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">How was this meal?</label>
|
||||
<StarRating value={rating} onChange={setRating} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="neverSuggest"
|
||||
checked={neverSuggest}
|
||||
onChange={(e) => setNeverSuggest(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="neverSuggest" className="text-sm text-gray-700">
|
||||
Never suggest this recipe again
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Why not? (optional)</label>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{DENIAL_REASONS.map(r => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Additional comments</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Anything else you'd like to share..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitMutation.isPending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitMutation.isPending ? 'Saving...' : 'Submit Feedback'}
|
||||
</button>
|
||||
|
||||
{submitMutation.isError && (
|
||||
<p className="text-sm text-red-600">Failed to save feedback. Please try again.</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
@@ -116,4 +275,4 @@ export default function MealDetail() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,4 +151,16 @@ export interface SystemStats {
|
||||
recipes: number
|
||||
ingredients: number
|
||||
meal_plans: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: string
|
||||
family_profile_id: string
|
||||
family_member_id?: string
|
||||
meal_plan_item_id: string
|
||||
rating?: number
|
||||
never_suggest: boolean
|
||||
denial_reason?: string
|
||||
feedback_text?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user