feat: allow adding pantry items by text input with auto-ingredient creation
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

- 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:
2026-05-17 21:31:37 -07:00
parent 986968b93d
commit 54b3e785b3
3 changed files with 89 additions and 22 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ export const mealPlannerApi = {
get: (id: string) => api.get(`/recipes/${id}`),
create: (data: any) => api.post('/recipes', data),
delete: (id: string) => api.delete(`/recipes/${id}`),
listIngredients: () => api.get('/recipes/ingredients'),
createIngredient: (data: any) => api.post('/recipes/ingredients', data),
listIngredients: () => api.get('/ingredients?limit=500'),
createIngredient: (data: any) => api.post('/ingredients', data),
},
meals: {
+67 -20
View File
@@ -6,7 +6,6 @@ import type { HomePantryItem, Ingredient } from '../types'
import { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { Select } from '../components/ui/Select'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { showToast } from '../lib/toast'
@@ -14,10 +13,13 @@ import { showToast } from '../lib/toast'
export default function Pantry() {
const queryClient = useQueryClient()
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 [unit, setUnit] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [aisle, setAisle] = useState('')
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
queryKey: ['pantry'],
@@ -29,15 +31,23 @@ export default function Pantry() {
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({
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
mealPlannerApi.pantry.add(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] })
setShowAddForm(false)
setSelectedIngredient('')
setIngredientName('')
setQuantity('')
setUnit('')
setAisle('')
showToast.success('Item added to pantry')
},
onError: () => {
@@ -56,12 +66,45 @@ export default function Pantry() {
},
})
const handleAdd = () => {
if (!selectedIngredient) return
async function handleAdd() {
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({
ingredient_id: selectedIngredient,
ingredient_id: ingredientId,
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())
)
const ingredientOptions = ingredients?.map(ing => ({
value: ing.id,
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
})) || []
if (isLoading) {
return (
<div className="space-y-6 animate-fade-in">
@@ -132,14 +170,17 @@ export default function Pantry() {
<Card className="animate-slide-down">
<CardBody>
<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">
<Select
label="Ingredient"
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]}
value={selectedIngredient}
onChange={(e) => setSelectedIngredient(e.target.value)}
<Input
label="Ingredient name"
value={ingredientName}
onChange={(e) => setIngredientName(e.target.value)}
placeholder="e.g., Avocado"
/>
{matchedIngredient && (
<p className="mt-1 text-xs text-success-600">Matched existing ingredient </p>
)}
</div>
<Input
label="Quantity"
@@ -154,11 +195,17 @@ export default function Pantry() {
onChange={(e) => setUnit(e.target.value)}
placeholder="cans, lbs, etc."
/>
<Input
label="Aisle"
value={aisle}
onChange={(e) => setAisle(e.target.value)}
placeholder="e.g., Produce"
/>
<div className="flex items-end">
<Button
onClick={handleAdd}
loading={addMutation.isPending}
disabled={!selectedIngredient || addMutation.isPending}
disabled={!ingredientName.trim() || addMutation.isPending}
className="w-full"
>
Add