Public Access
feat: allow adding pantry items by text input with auto-ingredient creation
- backend: expose POST /api/ingredients on public router so frontend can create ingredients without admin token - frontend/api: point listIngredients and createIngredient to /api/ingredients - frontend/pantry: replace ingredient dropdown with searchable text input + fuzzy matching + auto-create
This commit is contained in:
@@ -55,6 +55,26 @@ def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingred
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
||||||
|
row = Ingredient(
|
||||||
|
name=payload.name,
|
||||||
|
name_lower=payload.name.lower(),
|
||||||
|
aliases=payload.aliases,
|
||||||
|
aisle=payload.aisle,
|
||||||
|
unit=payload.unit,
|
||||||
|
typical_price=payload.typical_price,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="ingredient name already exists")
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
||||||
def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
||||||
row = Ingredient(
|
row = Ingredient(
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const mealPlannerApi = {
|
|||||||
get: (id: string) => api.get(`/recipes/${id}`),
|
get: (id: string) => api.get(`/recipes/${id}`),
|
||||||
create: (data: any) => api.post('/recipes', data),
|
create: (data: any) => api.post('/recipes', data),
|
||||||
delete: (id: string) => api.delete(`/recipes/${id}`),
|
delete: (id: string) => api.delete(`/recipes/${id}`),
|
||||||
listIngredients: () => api.get('/recipes/ingredients'),
|
listIngredients: () => api.get('/ingredients?limit=500'),
|
||||||
createIngredient: (data: any) => api.post('/recipes/ingredients', data),
|
createIngredient: (data: any) => api.post('/ingredients', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
meals: {
|
meals: {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { HomePantryItem, Ingredient } from '../types'
|
|||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Card, CardBody } from '../components/ui/Card'
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
import { Input } from '../components/ui/Input'
|
import { Input } from '../components/ui/Input'
|
||||||
import { Select } from '../components/ui/Select'
|
|
||||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
import { showToast } from '../lib/toast'
|
import { showToast } from '../lib/toast'
|
||||||
@@ -14,10 +13,13 @@ import { showToast } from '../lib/toast'
|
|||||||
export default function Pantry() {
|
export default function Pantry() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [selectedIngredient, setSelectedIngredient] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
/* ingredient name typed by user */
|
||||||
|
const [ingredientName, setIngredientName] = useState('')
|
||||||
const [quantity, setQuantity] = useState('')
|
const [quantity, setQuantity] = useState('')
|
||||||
const [unit, setUnit] = useState('')
|
const [unit, setUnit] = useState('')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [aisle, setAisle] = useState('')
|
||||||
|
|
||||||
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
||||||
queryKey: ['pantry'],
|
queryKey: ['pantry'],
|
||||||
@@ -29,15 +31,23 @@ export default function Pantry() {
|
|||||||
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
|
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* fuzzy match existing ingredient */
|
||||||
|
const matchedIngredient = ingredientName.trim().length > 0
|
||||||
|
? ingredients?.find(
|
||||||
|
ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
|
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
|
||||||
mealPlannerApi.pantry.add(data),
|
mealPlannerApi.pantry.add(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||||
setShowAddForm(false)
|
setShowAddForm(false)
|
||||||
setSelectedIngredient('')
|
setIngredientName('')
|
||||||
setQuantity('')
|
setQuantity('')
|
||||||
setUnit('')
|
setUnit('')
|
||||||
|
setAisle('')
|
||||||
showToast.success('Item added to pantry')
|
showToast.success('Item added to pantry')
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -56,12 +66,45 @@ export default function Pantry() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAdd = () => {
|
async function handleAdd() {
|
||||||
if (!selectedIngredient) return
|
const name = ingredientName.trim()
|
||||||
|
if (!name) {
|
||||||
|
showToast.error('Please enter an ingredient name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ingredientId = matchedIngredient?.id
|
||||||
|
|
||||||
|
if (!ingredientId) {
|
||||||
|
try {
|
||||||
|
const res = await mealPlannerApi.recipes.createIngredient({
|
||||||
|
name,
|
||||||
|
aisle: aisle.trim() || undefined,
|
||||||
|
unit: unit.trim() || undefined,
|
||||||
|
})
|
||||||
|
const created = res.data as any
|
||||||
|
ingredientId = created?.id
|
||||||
|
// refresh ingredient list so next add sees it
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
showToast.error('Ingredient name already exists')
|
||||||
|
} else {
|
||||||
|
showToast.error('Failed to create ingredient')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ingredientId) {
|
||||||
|
showToast.error('Could not resolve ingredient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
addMutation.mutate({
|
addMutation.mutate({
|
||||||
ingredient_id: selectedIngredient,
|
ingredient_id: ingredientId,
|
||||||
quantity: quantity ? parseFloat(quantity) : undefined,
|
quantity: quantity ? parseFloat(quantity) : undefined,
|
||||||
unit: unit || undefined,
|
unit: unit.trim() || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +113,6 @@ export default function Pantry() {
|
|||||||
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
|
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const ingredientOptions = ingredients?.map(ing => ({
|
|
||||||
value: ing.id,
|
|
||||||
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
|
|
||||||
})) || []
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
@@ -132,14 +170,17 @@ export default function Pantry() {
|
|||||||
<Card className="animate-slide-down">
|
<Card className="animate-slide-down">
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Select
|
<Input
|
||||||
label="Ingredient"
|
label="Ingredient name"
|
||||||
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]}
|
value={ingredientName}
|
||||||
value={selectedIngredient}
|
onChange={(e) => setIngredientName(e.target.value)}
|
||||||
onChange={(e) => setSelectedIngredient(e.target.value)}
|
placeholder="e.g., Avocado"
|
||||||
/>
|
/>
|
||||||
|
{matchedIngredient && (
|
||||||
|
<p className="mt-1 text-xs text-success-600">Matched existing ingredient ✓</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="Quantity"
|
label="Quantity"
|
||||||
@@ -154,11 +195,17 @@ export default function Pantry() {
|
|||||||
onChange={(e) => setUnit(e.target.value)}
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
placeholder="cans, lbs, etc."
|
placeholder="cans, lbs, etc."
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="Aisle"
|
||||||
|
value={aisle}
|
||||||
|
onChange={(e) => setAisle(e.target.value)}
|
||||||
|
placeholder="e.g., Produce"
|
||||||
|
/>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
loading={addMutation.isPending}
|
loading={addMutation.isPending}
|
||||||
disabled={!selectedIngredient || addMutation.isPending}
|
disabled={!ingredientName.trim() || addMutation.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
|
|||||||
Reference in New Issue
Block a user