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:
@@ -0,0 +1,62 @@
|
|||||||
|
# context-mode — MANDATORY routing rules
|
||||||
|
|
||||||
|
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||||
|
|
||||||
|
## BLOCKED commands — do NOT attempt these
|
||||||
|
|
||||||
|
### curl / wget — BLOCKED
|
||||||
|
Any Bash command containing `curl` or `wget` is intercepted and replaced with an error message. Do NOT retry.
|
||||||
|
Instead use:
|
||||||
|
- `ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||||
|
- `ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||||
|
|
||||||
|
### Inline HTTP — BLOCKED
|
||||||
|
Any Bash command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` is intercepted and replaced with an error message. Do NOT retry with Bash.
|
||||||
|
Instead use:
|
||||||
|
- `ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||||
|
|
||||||
|
### WebFetch — BLOCKED
|
||||||
|
WebFetch calls are denied entirely. The URL is extracted and you are told to use `ctx_fetch_and_index` instead.
|
||||||
|
Instead use:
|
||||||
|
- `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` to query the indexed content
|
||||||
|
|
||||||
|
## REDIRECTED tools — use sandbox equivalents
|
||||||
|
|
||||||
|
### Bash (>20 lines output)
|
||||||
|
Bash is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||||
|
For everything else, use:
|
||||||
|
- `ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||||
|
- `ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||||
|
|
||||||
|
### Read (for analysis)
|
||||||
|
If you are reading a file to **Edit** it → Read is correct (Edit needs content in context).
|
||||||
|
If you are reading to **analyze, explore, or summarize** → use `ctx_execute_file(path, language, code)` instead. Only your printed summary enters context. The raw file content stays in the sandbox.
|
||||||
|
|
||||||
|
### Grep (large results)
|
||||||
|
Grep results can flood context. Use `ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||||
|
|
||||||
|
## Tool selection hierarchy
|
||||||
|
|
||||||
|
1. **GATHER**: `ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||||
|
2. **FOLLOW-UP**: `ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||||
|
3. **PROCESSING**: `ctx_execute(language, code)` | `ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||||
|
4. **WEB**: `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||||
|
5. **INDEX**: `ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||||
|
|
||||||
|
## Subagent routing
|
||||||
|
|
||||||
|
When spawning subagents (Agent/Task tool), the routing block is automatically injected into their prompt. Bash-type subagents are upgraded to general-purpose so they have access to MCP tools. You do NOT need to manually instruct subagents about context-mode.
|
||||||
|
|
||||||
|
## Output constraints
|
||||||
|
|
||||||
|
- Keep responses under 500 words.
|
||||||
|
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||||
|
- When indexing content, use descriptive source labels so others can `ctx_search(source: "label")` later.
|
||||||
|
|
||||||
|
## ctx commands
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `ctx stats` | Call the `ctx_stats` MCP tool and display the full output verbatim |
|
||||||
|
| `ctx doctor` | Call the `ctx_doctor` MCP tool, run the returned shell command, display as checklist |
|
||||||
|
| `ctx upgrade` | Call the `ctx_upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import FamilyProfile, Feedback, MealPlanItem
|
||||||
|
from app.schemas import FeedbackCreate, FeedbackResponse
|
||||||
|
from app.security import require_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{meal_plan_item_id}", response_model=FeedbackResponse | None)
|
||||||
|
def get_feedback(
|
||||||
|
meal_plan_item_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
family_id: str = Depends(require_session),
|
||||||
|
):
|
||||||
|
"""Get existing feedback for a meal plan item."""
|
||||||
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == meal_plan_item_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
||||||
|
|
||||||
|
feedback = (
|
||||||
|
db.query(Feedback)
|
||||||
|
.filter(
|
||||||
|
Feedback.meal_plan_item_id == meal_plan_item_id,
|
||||||
|
Feedback.family_profile_id == UUID(family_id),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return feedback
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FeedbackResponse)
|
||||||
|
def create_feedback(
|
||||||
|
data: FeedbackCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
family_id: str = Depends(require_session),
|
||||||
|
):
|
||||||
|
"""Create or update feedback for a meal plan item."""
|
||||||
|
item = db.query(MealPlanItem).filter(
|
||||||
|
MealPlanItem.id == data.meal_plan_item_id
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
||||||
|
|
||||||
|
profile = db.query(FamilyProfile).filter(FamilyProfile.id == UUID(family_id)).first()
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
db.query(Feedback)
|
||||||
|
.filter(
|
||||||
|
Feedback.meal_plan_item_id == data.meal_plan_item_id,
|
||||||
|
Feedback.family_profile_id == UUID(family_id),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.rating = data.rating
|
||||||
|
existing.never_suggest = data.never_suggest
|
||||||
|
existing.denial_reason = data.denial_reason.value if data.denial_reason else None
|
||||||
|
existing.feedback_text = data.feedback_text
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
feedback = Feedback(
|
||||||
|
family_profile_id=UUID(family_id),
|
||||||
|
meal_plan_item_id=data.meal_plan_item_id,
|
||||||
|
rating=data.rating,
|
||||||
|
never_suggest=data.never_suggest,
|
||||||
|
denial_reason=data.denial_reason.value if data.denial_reason else None,
|
||||||
|
feedback_text=data.feedback_text,
|
||||||
|
)
|
||||||
|
db.add(feedback)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(feedback)
|
||||||
|
return feedback
|
||||||
@@ -34,6 +34,7 @@ from app.api import ingredients as ingredients_api
|
|||||||
from app.api import recipes as recipes_api
|
from app.api import recipes as recipes_api
|
||||||
from app.api import never_suggest as never_suggest_api
|
from app.api import never_suggest as never_suggest_api
|
||||||
from app.api import meal_plans as meal_plans_api
|
from app.api import meal_plans as meal_plans_api
|
||||||
|
from app.api import feedback as feedback_api
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
@@ -50,3 +51,4 @@ app.include_router(never_suggest_api.public_router)
|
|||||||
app.include_router(never_suggest_api.admin_router)
|
app.include_router(never_suggest_api.admin_router)
|
||||||
app.include_router(meal_plans_api.admin_router)
|
app.include_router(meal_plans_api.admin_router)
|
||||||
app.include_router(meal_plans_api.public_router)
|
app.include_router(meal_plans_api.public_router)
|
||||||
|
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -122,10 +122,21 @@ class FamilyProfileUpdate(BaseModel):
|
|||||||
|
|
||||||
class RecipeIngredient(BaseModel):
|
class RecipeIngredient(BaseModel):
|
||||||
ingredient_id: Optional[UUID] = None
|
ingredient_id: Optional[UUID] = None
|
||||||
name: str
|
name: Optional[str] = None
|
||||||
quantity: Optional[float] = None
|
quantity: Optional[float] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
is_optional: bool = False
|
is_optional: bool = False
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize(cls, data):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
# JSONB stores qty; schema uses quantity
|
||||||
|
if "qty" in data and "quantity" not in data:
|
||||||
|
data["quantity"] = data.pop("qty")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class RecipeBase(BaseModel):
|
class RecipeBase(BaseModel):
|
||||||
|
|||||||
+12
-20
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
You are taking over a project in mid-flight. Read `docs/ORIENTATION.md` first for the high-level. This file is the deep dive: what's real, what's stubbed, where the bodies are buried, and what to do next.
|
You are taking over a project in mid-flight. Read `docs/ORIENTATION.md` first for the high-level. This file is the deep dive: what's real, what's stubbed, where the bodies are buried, and what to do next.
|
||||||
|
|
||||||
**Date of handoff: 2026-05-12. Last commits before handoff:**
|
**Date of handoff: 2026-05-14. Last commits before handoff:**
|
||||||
```
|
```
|
||||||
|
[pending] feat: Phase 8 Feedback UI + API endpoints
|
||||||
|
[pending] fix: RecipeIngredient schema qty→quantity model_validator
|
||||||
b522760 fix: cast qty/unit to str before html.escape in vote email shopping preview
|
b522760 fix: cast qty/unit to str before html.escape in vote email shopping preview
|
||||||
dbc26bc feat: LLM-powered second-pass ingredient matcher + matcher improvements
|
|
||||||
b03e7f8 feat: Spoonacular recipe enrichment — images + descriptions for 25/30 recipes
|
|
||||||
a98f0dc docs: update HANDOFF.md for 2026-05-10 session
|
|
||||||
2373883 fix: exact-name fast path in matcher + save priceless produce in scraper
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,8 +180,9 @@ The Swiftly API is the same for ALL product categories, not just the weekly ad:
|
|||||||
- These show "—" in shopping list — correct behavior (better than wrong match)
|
- These show "—" in shopping list — correct behavior (better than wrong match)
|
||||||
- **Approved plan:** Ollama LLM matcher as a second pass using Lucky's product search API (`luckysupermarkets.com/search/products?q=<ingredient>`) to find items outside the Swiftly weekly ad
|
- **Approved plan:** Ollama LLM matcher as a second pass using Lucky's product search API (`luckysupermarkets.com/search/products?q=<ingredient>`) to find items outside the Swiftly weekly ad
|
||||||
|
|
||||||
### Phase 8 — Feedback UI (not started)
|
### Phase 8 — Feedback UI (done)
|
||||||
- `feedback` table exists; no UI reads/writes it
|
- `feedback` table now read/written via REST API
|
||||||
|
- Meal detail page shows star rating, never-suggest, reason dropdown, free-text comments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -274,18 +273,11 @@ Next Friday at 02:00 PT the scheduler runs automatically. No action needed. All
|
|||||||
## File map (additions from this session)
|
## File map (additions from this session)
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/app/scraper/lucky_ca_scraper.py — removed price guard in map_product()
|
backend/app/api/feedback.py — new: GET/POST feedback endpoints
|
||||||
backend/app/services/matcher.py — full rewrite: ingredient-centric, precision×recall
|
frontend/src/pages/MealDetail.tsx — added Feedback section (rating, never-suggest, reasons)
|
||||||
backend/app/services/orchestrator/
|
frontend/src/api/index.ts — added feedback API methods
|
||||||
steps.py — vote email: ingredient list + cooking steps
|
frontend/src/types/index.ts — added Feedback interface
|
||||||
shopping list: grouped by meal, current_price fix
|
backend/app/schemas/__init__.py — RecipeIngredient model_validator qty→quantity
|
||||||
nginx/nginx.conf — Docker DNS resolver, proxy via set $var pattern
|
|
||||||
frontend/src/pages/Login.tsx — password form (Login page)
|
|
||||||
frontend/src/App.tsx — /login route, Sign out button
|
|
||||||
frontend/src/api/index.ts — 401 interceptor
|
|
||||||
scripts/seed_family.py — Woolery family + real emails
|
|
||||||
docs/superpowers/plans/
|
|
||||||
2026-05-09-mvp-login.md — MVP login plan (executed, merged)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -294,4 +286,4 @@ docs/superpowers/plans/
|
|||||||
|
|
||||||
Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself.
|
Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself.
|
||||||
|
|
||||||
**Last updated: 2026-05-12** — Spoonacular enrichment (102/107 recipes), LLM matcher built (Ollama Cloud kimi-k2.6), AUTO matcher improved (plural normalization, precision floor 0.45→0.30), vote email confirmed polished by Peter. Next: run Spoonacular script again for 5 remaining recipes; Phase 8 Feedback UI.
|
**Last updated: 2026-05-14** — Spoonacular enrichment complete (102/107 matched, 5 unmatched). Phase 8 Feedback UI built: REST API + frontend. Pydantic validation fixed for recipe ingredients (qty↔quantity schema drift). .env restored after accidental overwrite.
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export const mealPlannerApi = {
|
|||||||
getStats: () => api.get('/admin/stats'),
|
getStats: () => api.get('/admin/stats'),
|
||||||
testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }),
|
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
|
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 { useParams } from 'react-router-dom'
|
||||||
import { mealPlannerApi } from '../api'
|
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() {
|
export default function MealDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: item, isLoading } = useQuery<MealPlanItem>({
|
const { data: item, isLoading } = useQuery<MealPlanItem>({
|
||||||
queryKey: ['mealItem', id],
|
queryKey: ['mealItem', id],
|
||||||
@@ -12,6 +41,26 @@ export default function MealDetail() {
|
|||||||
enabled: !!id,
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="flex justify-center items-center py-12">
|
||||||
@@ -30,6 +79,19 @@ export default function MealDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recipe = item.recipe
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -100,6 +162,103 @@ export default function MealDetail() {
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</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">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
onClick={() => window.print()}
|
||||||
|
|||||||
@@ -152,3 +152,15 @@ export interface SystemStats {
|
|||||||
ingredients: number
|
ingredients: number
|
||||||
meal_plans: 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