Adds minimal sort/search to the recipe browser for cognitive access diversity — linear scanners, alphabet browsers, and keyword diggers each get a different way in without duplicating the full search tab. - browse_recipes: q (LIKE title filter) + sort (default/alpha/alpha_desc) - API endpoint: q/sort query params with validation - Frontend: debounced search input (350ms) + sort pills (Default/A→Z/Z→A) - Search and sort reset on domain/category change - _all path supports q+sort; keyword-FTS path adds AND filter on top
1031 lines
26 KiB
TypeScript
1031 lines
26 KiB
TypeScript
/**
|
|
* API Service for Kiwi Backend
|
|
*
|
|
* VITE_API_BASE is baked in at build time:
|
|
* dev: '' (empty — proxy in vite.config.ts handles /api/)
|
|
* cloud: '/kiwi' (Caddy strips /kiwi and forwards to nginx, which proxies /api/ → api container)
|
|
*/
|
|
|
|
import axios, { type AxiosInstance } from 'axios'
|
|
|
|
// API Configuration
|
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE ?? '') + '/api/v1'
|
|
|
|
// Create axios instance
|
|
const api: AxiosInstance = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 30000, // 30 seconds
|
|
})
|
|
|
|
// Request interceptor for logging
|
|
api.interceptors.request.use(
|
|
(config) => {
|
|
console.log(`[API] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`, {
|
|
params: config.params,
|
|
data: config.data instanceof FormData ? '<FormData>' : config.data,
|
|
})
|
|
return config
|
|
},
|
|
(error) => {
|
|
console.error('[API Request Error]', error)
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// Response interceptor for error handling
|
|
api.interceptors.response.use(
|
|
(response) => {
|
|
console.log(`[API] ✓ ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`)
|
|
return response
|
|
},
|
|
(error) => {
|
|
console.error('[API Error]', {
|
|
message: error.message,
|
|
url: error.config?.url,
|
|
method: error.config?.method?.toUpperCase(),
|
|
status: error.response?.status,
|
|
statusText: error.response?.statusText,
|
|
data: error.response?.data,
|
|
baseURL: error.config?.baseURL,
|
|
})
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// ========== Types ==========
|
|
|
|
export interface Product {
|
|
id: string
|
|
barcode: string | null
|
|
name: string
|
|
brand: string | null
|
|
category: string | null
|
|
description: string | null
|
|
image_url: string | null
|
|
nutrition_data: Record<string, any>
|
|
source: string
|
|
tags: Tag[]
|
|
}
|
|
|
|
export interface Tag {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
description: string | null
|
|
color: string | null
|
|
category: string | null
|
|
}
|
|
|
|
export interface InventoryItem {
|
|
id: number
|
|
product_id: number
|
|
product_name: string | null
|
|
barcode: string | null
|
|
category: string | null
|
|
quantity: number
|
|
unit: string
|
|
location: string
|
|
sublocation: string | null
|
|
purchase_date: string | null
|
|
expiration_date: string | null
|
|
opened_date: string | null
|
|
opened_expiry_date: string | null
|
|
secondary_state: string | null
|
|
secondary_uses: string[] | null
|
|
secondary_warning: string | null
|
|
status: string
|
|
source: string
|
|
notes: string | null
|
|
disposal_reason: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface BarcodeScanResult {
|
|
barcode: string
|
|
barcode_type: string
|
|
product: Product | null
|
|
inventory_item: InventoryItem | null
|
|
added_to_inventory: boolean
|
|
needs_manual_entry: boolean
|
|
message: string
|
|
}
|
|
|
|
export interface BarcodeScanResponse {
|
|
success: boolean
|
|
barcodes_found: number
|
|
results: BarcodeScanResult[]
|
|
message: string
|
|
}
|
|
|
|
export interface InventoryItemUpdate {
|
|
quantity?: number
|
|
unit?: string
|
|
location?: string
|
|
sublocation?: string | null
|
|
purchase_date?: string | null
|
|
expiration_date?: string | null
|
|
status?: string
|
|
notes?: string | null
|
|
}
|
|
|
|
export interface InventoryStats {
|
|
total_items: number
|
|
available_items: number
|
|
expiring_soon: number
|
|
expired_items: number
|
|
locations: Record<string, number>
|
|
}
|
|
|
|
export interface Receipt {
|
|
id: string
|
|
filename: string
|
|
status: string
|
|
metadata: Record<string, any>
|
|
quality_score: number | null
|
|
}
|
|
|
|
export interface ReceiptOCRData {
|
|
id: string
|
|
receipt_id: string
|
|
merchant: {
|
|
name: string | null
|
|
address: string | null
|
|
phone: string | null
|
|
}
|
|
transaction: {
|
|
date: string | null
|
|
time: string | null
|
|
receipt_number: string | null
|
|
register: string | null
|
|
cashier: string | null
|
|
}
|
|
items: Array<{
|
|
name: string
|
|
quantity: number
|
|
unit_price: number | null
|
|
total_price: number
|
|
category: string | null
|
|
}>
|
|
totals: {
|
|
subtotal: number | null
|
|
tax: number | null
|
|
total: number | null
|
|
payment_method: string | null
|
|
}
|
|
confidence: Record<string, number>
|
|
warnings: string[]
|
|
processing_time: number | null
|
|
created_at: string
|
|
}
|
|
|
|
// ========== Inventory API ==========
|
|
|
|
export const inventoryAPI = {
|
|
/**
|
|
* List all inventory items
|
|
*/
|
|
async listItems(params?: {
|
|
location?: string
|
|
item_status?: string
|
|
limit?: number
|
|
offset?: number
|
|
}): Promise<InventoryItem[]> {
|
|
const response = await api.get('/inventory/items', { params })
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get a single inventory item
|
|
*/
|
|
async getItem(itemId: string): Promise<InventoryItem> {
|
|
const response = await api.get(`/inventory/items/${itemId}`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Update an inventory item
|
|
*/
|
|
async updateItem(itemId: number, update: InventoryItemUpdate): Promise<InventoryItem> {
|
|
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Delete an inventory item
|
|
*/
|
|
async deleteItem(itemId: number): Promise<void> {
|
|
await api.delete(`/inventory/items/${itemId}`)
|
|
},
|
|
|
|
/**
|
|
* Get inventory statistics
|
|
*/
|
|
async getStats(): Promise<InventoryStats> {
|
|
const response = await api.get('/inventory/stats')
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get items expiring soon
|
|
*/
|
|
async getExpiring(days: number = 7): Promise<any[]> {
|
|
const response = await api.get(`/inventory/expiring?days=${days}`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Scan barcode from text
|
|
*/
|
|
async scanBarcodeText(
|
|
barcode: string,
|
|
location: string = 'pantry',
|
|
quantity: number = 1.0,
|
|
autoAdd: boolean = true
|
|
): Promise<BarcodeScanResponse> {
|
|
const response = await api.post('/inventory/scan/text', {
|
|
barcode,
|
|
location,
|
|
quantity,
|
|
auto_add_to_inventory: autoAdd,
|
|
})
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Mark item as consumed fully or partially.
|
|
* Pass quantity to decrement; omit to consume all.
|
|
*/
|
|
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
|
|
const body = quantity !== undefined ? { quantity } : undefined
|
|
const response = await api.post(`/inventory/items/${itemId}/consume`, body)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Mark item as discarded (not used, spoiled, etc).
|
|
*/
|
|
async discardItem(itemId: number, reason?: string): Promise<InventoryItem> {
|
|
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Mark item as opened today — starts secondary shelf-life tracking
|
|
*/
|
|
async openItem(itemId: number): Promise<InventoryItem> {
|
|
const response = await api.post(`/inventory/items/${itemId}/open`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Create a new product
|
|
*/
|
|
async createProduct(data: {
|
|
name: string
|
|
brand?: string
|
|
source?: string
|
|
}): Promise<Product> {
|
|
const response = await api.post('/inventory/products', data)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Create a new inventory item
|
|
*/
|
|
async createItem(data: {
|
|
product_id: string
|
|
quantity: number
|
|
unit?: string
|
|
location: string
|
|
expiration_date?: string
|
|
source?: string
|
|
}): Promise<InventoryItem> {
|
|
const response = await api.post('/inventory/items', data)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Bulk-add items by ingredient name (no barcode required).
|
|
* Idempotent: re-adding an existing product just creates a new inventory entry.
|
|
*/
|
|
async bulkAddByName(items: Array<{
|
|
name: string
|
|
quantity?: number
|
|
unit?: string
|
|
location?: string
|
|
}>): Promise<{ added: number; failed: number; results: Array<{ name: string; ok: boolean; item_id?: number; error?: string }> }> {
|
|
const response = await api.post('/inventory/items/bulk-add-by-name', { items })
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Scan barcode from image
|
|
*/
|
|
async scanBarcodeImage(
|
|
file: File,
|
|
location: string = 'pantry',
|
|
quantity: number = 1.0,
|
|
autoAdd: boolean = true
|
|
): Promise<any> {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('location', location)
|
|
formData.append('quantity', quantity.toString())
|
|
formData.append('auto_add_to_inventory', autoAdd.toString())
|
|
|
|
const response = await api.post('/inventory/scan', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
})
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// ========== Receipts API ==========
|
|
|
|
export const receiptsAPI = {
|
|
/**
|
|
* List all receipts
|
|
*/
|
|
async listReceipts(): Promise<Receipt[]> {
|
|
const response = await api.get('/receipts/')
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get a single receipt
|
|
*/
|
|
async getReceipt(receiptId: string): Promise<Receipt> {
|
|
const response = await api.get(`/receipts/${receiptId}`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Upload a receipt
|
|
*/
|
|
async upload(file: File): Promise<Receipt> {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const response = await api.post('/receipts/', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
})
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get receipt statistics
|
|
*/
|
|
async getStats(): Promise<any> {
|
|
const response = await api.get('/export/stats')
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get OCR data for a receipt
|
|
*/
|
|
async getOCRData(receiptId: string): Promise<ReceiptOCRData> {
|
|
const response = await api.get(`/receipts/${receiptId}/ocr/data`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Get OCR status for a receipt
|
|
*/
|
|
async getOCRStatus(receiptId: string): Promise<any> {
|
|
const response = await api.get(`/receipts/${receiptId}/ocr/status`)
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Trigger OCR processing
|
|
*/
|
|
async triggerOCR(receiptId: string, forceReprocess: boolean = false): Promise<any> {
|
|
const response = await api.post(`/receipts/${receiptId}/ocr/trigger`, {
|
|
force_reprocess: forceReprocess,
|
|
})
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// ========== Export API ==========
|
|
|
|
export const exportAPI = {
|
|
/**
|
|
* Get export statistics
|
|
*/
|
|
async getStats(): Promise<any> {
|
|
const response = await api.get('/export/stats')
|
|
return response.data
|
|
},
|
|
|
|
/**
|
|
* Download inventory CSV
|
|
*/
|
|
getInventoryCSVUrl(location?: string, status: string = 'available'): string {
|
|
const params = new URLSearchParams()
|
|
if (location) params.append('location', location)
|
|
params.append('status', status)
|
|
return `${API_BASE_URL}/export/inventory/csv?${params.toString()}`
|
|
},
|
|
|
|
/**
|
|
* Download inventory Excel
|
|
*/
|
|
getInventoryExcelUrl(location?: string, status: string = 'available'): string {
|
|
const params = new URLSearchParams()
|
|
if (location) params.append('location', location)
|
|
params.append('status', status)
|
|
return `${API_BASE_URL}/export/inventory/excel?${params.toString()}`
|
|
},
|
|
|
|
/**
|
|
* Download receipts CSV
|
|
*/
|
|
getReceiptsCSVUrl(): string {
|
|
return `${API_BASE_URL}/export/csv`
|
|
},
|
|
|
|
/**
|
|
* Download receipts Excel
|
|
*/
|
|
getReceiptsExcelUrl(): string {
|
|
return `${API_BASE_URL}/export/excel`
|
|
},
|
|
}
|
|
|
|
// ========== Recipes & Settings Types ==========
|
|
|
|
export interface SwapCandidate {
|
|
original_name: string
|
|
substitute_name: string
|
|
constraint_label: string
|
|
explanation: string
|
|
compensation_hints: Record<string, string>[]
|
|
}
|
|
|
|
export interface NutritionPanel {
|
|
calories: number | null
|
|
fat_g: number | null
|
|
protein_g: number | null
|
|
carbs_g: number | null
|
|
fiber_g: number | null
|
|
sugar_g: number | null
|
|
sodium_mg: number | null
|
|
servings: number | null
|
|
estimated: boolean
|
|
}
|
|
|
|
export interface RecipeSuggestion {
|
|
id: number
|
|
title: string
|
|
match_count: number
|
|
element_coverage: Record<string, number>
|
|
swap_candidates: SwapCandidate[]
|
|
matched_ingredients: string[]
|
|
missing_ingredients: string[]
|
|
directions: string[]
|
|
prep_notes: string[]
|
|
notes: string
|
|
level: number
|
|
is_wildcard: boolean
|
|
nutrition: NutritionPanel | null
|
|
source_url: string | null
|
|
complexity: 'easy' | 'moderate' | 'involved' | null
|
|
estimated_time_min: number | null
|
|
}
|
|
|
|
export interface NutritionFilters {
|
|
max_calories: number | null
|
|
max_sugar_g: number | null
|
|
max_carbs_g: number | null
|
|
max_sodium_mg: number | null
|
|
}
|
|
|
|
export interface GroceryLink {
|
|
ingredient: string
|
|
retailer: string
|
|
url: string
|
|
}
|
|
|
|
export interface RecipeResult {
|
|
suggestions: RecipeSuggestion[]
|
|
element_gaps: string[]
|
|
grocery_list: string[]
|
|
grocery_links: GroceryLink[]
|
|
rate_limited: boolean
|
|
rate_limit_count: number
|
|
}
|
|
|
|
export interface RecipeRequest {
|
|
pantry_items: string[]
|
|
secondary_pantry_items: Record<string, string>
|
|
level: number
|
|
constraints: string[]
|
|
allergies: string[]
|
|
expiry_first: boolean
|
|
hard_day_mode: boolean
|
|
max_missing: number | null
|
|
style_id: string | null
|
|
category: string | null
|
|
wildcard_confirmed: boolean
|
|
nutrition_filters: NutritionFilters
|
|
excluded_ids: number[]
|
|
shopping_mode: boolean
|
|
pantry_match_only: boolean
|
|
complexity_filter: string | null
|
|
max_time_min: number | null
|
|
}
|
|
|
|
export interface Staple {
|
|
slug: string
|
|
name: string
|
|
category: string
|
|
dietary_tags: string[]
|
|
}
|
|
|
|
// ── Build Your Own types ──────────────────────────────────────────────────
|
|
|
|
export interface AssemblyRoleOut {
|
|
display: string
|
|
required: boolean
|
|
keywords: string[]
|
|
hint: string
|
|
}
|
|
|
|
export interface AssemblyTemplateOut {
|
|
id: string
|
|
title: string
|
|
icon: string
|
|
descriptor: string
|
|
role_sequence: AssemblyRoleOut[]
|
|
}
|
|
|
|
export interface RoleCandidateItem {
|
|
name: string
|
|
in_pantry: boolean
|
|
tags: string[]
|
|
}
|
|
|
|
export interface RoleCandidatesResponse {
|
|
compatible: RoleCandidateItem[]
|
|
other: RoleCandidateItem[]
|
|
available_tags: string[]
|
|
}
|
|
|
|
export interface BuildRequest {
|
|
template_id: string
|
|
role_overrides: Record<string, string>
|
|
}
|
|
|
|
// ========== Recipes API ==========
|
|
|
|
export const recipesAPI = {
|
|
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
|
// Allow up to 120s — cf-orch model cold-start can take 60+ seconds on first request
|
|
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
|
return response.data
|
|
},
|
|
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
|
const response = await api.get(`/recipes/${id}`)
|
|
return response.data
|
|
},
|
|
async listStaples(dietary?: string): Promise<Staple[]> {
|
|
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
|
return response.data
|
|
},
|
|
async getTemplates(): Promise<AssemblyTemplateOut[]> {
|
|
const response = await api.get('/recipes/templates')
|
|
return response.data
|
|
},
|
|
async getRoleCandidates(
|
|
templateId: string,
|
|
role: string,
|
|
priorPicks: string[] = [],
|
|
): Promise<RoleCandidatesResponse> {
|
|
const response = await api.get('/recipes/template-candidates', {
|
|
params: {
|
|
template_id: templateId,
|
|
role,
|
|
prior_picks: priorPicks.join(','),
|
|
},
|
|
})
|
|
return response.data
|
|
},
|
|
async buildRecipe(req: BuildRequest): Promise<RecipeSuggestion> {
|
|
const response = await api.post('/recipes/build', req)
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// ========== Settings API ==========
|
|
|
|
export const settingsAPI = {
|
|
async getSetting(key: string): Promise<string | null> {
|
|
try {
|
|
const response = await api.get(`/settings/${key}`)
|
|
return response.data.value
|
|
} catch {
|
|
return null
|
|
}
|
|
},
|
|
async setSetting(key: string, value: string): Promise<void> {
|
|
await api.put(`/settings/${key}`, { value })
|
|
},
|
|
}
|
|
|
|
// ========== Household Types ==========
|
|
|
|
export interface HouseholdMember {
|
|
user_id: string
|
|
joined_at: string
|
|
is_owner: boolean
|
|
}
|
|
|
|
export interface HouseholdStatus {
|
|
in_household: boolean
|
|
household_id: string | null
|
|
is_owner: boolean
|
|
members: HouseholdMember[]
|
|
max_seats: number
|
|
}
|
|
|
|
export interface HouseholdInvite {
|
|
invite_url: string
|
|
token: string
|
|
expires_at: string
|
|
}
|
|
|
|
// ========== Household API ==========
|
|
|
|
export const householdAPI = {
|
|
async create(): Promise<{ household_id: string; message: string }> {
|
|
const response = await api.post('/household/create')
|
|
return response.data
|
|
},
|
|
async status(): Promise<HouseholdStatus> {
|
|
const response = await api.get('/household/status')
|
|
return response.data
|
|
},
|
|
async invite(): Promise<HouseholdInvite> {
|
|
const response = await api.post('/household/invite')
|
|
return response.data
|
|
},
|
|
async accept(householdId: string, token: string): Promise<{ message: string; household_id: string }> {
|
|
const response = await api.post('/household/accept', { household_id: householdId, token })
|
|
return response.data
|
|
},
|
|
async leave(): Promise<{ message: string }> {
|
|
const response = await api.post('/household/leave')
|
|
return response.data
|
|
},
|
|
async removeMember(userId: string): Promise<{ message: string }> {
|
|
const response = await api.post('/household/remove-member', { user_id: userId })
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// ========== Saved Recipes Types ==========
|
|
|
|
export interface SavedRecipe {
|
|
id: number
|
|
recipe_id: number
|
|
title: string
|
|
saved_at: string
|
|
notes: string | null
|
|
rating: number | null
|
|
style_tags: string[]
|
|
collection_ids: number[]
|
|
}
|
|
|
|
export interface RecipeCollection {
|
|
id: number
|
|
name: string
|
|
description: string | null
|
|
member_count: number
|
|
created_at: string
|
|
}
|
|
|
|
// ========== Saved Recipes API ==========
|
|
|
|
export const savedRecipesAPI = {
|
|
async save(recipe_id: number, notes?: string, rating?: number): Promise<SavedRecipe> {
|
|
const response = await api.post('/recipes/saved', { recipe_id, notes, rating })
|
|
return response.data
|
|
},
|
|
async unsave(recipe_id: number): Promise<void> {
|
|
await api.delete(`/recipes/saved/${recipe_id}`)
|
|
},
|
|
async update(recipe_id: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
|
|
const response = await api.patch(`/recipes/saved/${recipe_id}`, data)
|
|
return response.data
|
|
},
|
|
async list(params?: { sort_by?: string; collection_id?: number }): Promise<SavedRecipe[]> {
|
|
const response = await api.get('/recipes/saved', { params })
|
|
return response.data
|
|
},
|
|
async listCollections(): Promise<RecipeCollection[]> {
|
|
const response = await api.get('/recipes/saved/collections')
|
|
return response.data
|
|
},
|
|
async createCollection(name: string, description?: string): Promise<RecipeCollection> {
|
|
const response = await api.post('/recipes/saved/collections', { name, description })
|
|
return response.data
|
|
},
|
|
async deleteCollection(id: number): Promise<void> {
|
|
await api.delete(`/recipes/saved/collections/${id}`)
|
|
},
|
|
async addToCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
|
await api.post(`/recipes/saved/collections/${collection_id}/members`, { saved_recipe_id })
|
|
},
|
|
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
|
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
|
},
|
|
}
|
|
|
|
// --- Meal Plan types ---
|
|
|
|
export interface MealPlanSlot {
|
|
id: number
|
|
plan_id: number
|
|
day_of_week: number // 0 = Monday
|
|
meal_type: string
|
|
recipe_id: number | null
|
|
recipe_title: string | null
|
|
servings: number
|
|
custom_label: string | null
|
|
}
|
|
|
|
export interface MealPlan {
|
|
id: number
|
|
week_start: string // ISO date, e.g. "2026-04-13"
|
|
meal_types: string[]
|
|
slots: MealPlanSlot[]
|
|
created_at: string
|
|
}
|
|
|
|
export interface RetailerLink {
|
|
retailer: string
|
|
label: string
|
|
url: string
|
|
}
|
|
|
|
export interface GapItem {
|
|
ingredient_name: string
|
|
needed_raw: string | null
|
|
have_quantity: number | null
|
|
have_unit: string | null
|
|
covered: boolean
|
|
retailer_links: RetailerLink[]
|
|
}
|
|
|
|
export interface ShoppingList {
|
|
plan_id: number
|
|
gap_items: GapItem[]
|
|
covered_items: GapItem[]
|
|
disclosure: string | null
|
|
}
|
|
|
|
export interface PrepTask {
|
|
id: number
|
|
recipe_id: number | null
|
|
task_label: string
|
|
duration_minutes: number | null
|
|
sequence_order: number
|
|
equipment: string | null
|
|
is_parallel: boolean
|
|
notes: string | null
|
|
user_edited: boolean
|
|
}
|
|
|
|
export interface PrepSession {
|
|
id: number
|
|
plan_id: number
|
|
scheduled_date: string
|
|
status: 'draft' | 'reviewed' | 'done'
|
|
tasks: PrepTask[]
|
|
}
|
|
|
|
// --- Meal Plan API ---
|
|
|
|
export const mealPlanAPI = {
|
|
async list(): Promise<MealPlan[]> {
|
|
const resp = await api.get<MealPlan[]>('/meal-plans/')
|
|
return resp.data
|
|
},
|
|
|
|
async create(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
|
|
const resp = await api.post<MealPlan>('/meal-plans/', { week_start: weekStart, meal_types: mealTypes })
|
|
return resp.data
|
|
},
|
|
|
|
async get(planId: number): Promise<MealPlan> {
|
|
const resp = await api.get<MealPlan>(`/meal-plans/${planId}`)
|
|
return resp.data
|
|
},
|
|
|
|
async updateMealTypes(planId: number, mealTypes: string[]): Promise<MealPlan> {
|
|
const resp = await api.patch<MealPlan>(`/meal-plans/${planId}`, { meal_types: mealTypes })
|
|
return resp.data
|
|
},
|
|
|
|
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
|
|
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
|
|
return resp.data
|
|
},
|
|
|
|
async deleteSlot(planId: number, slotId: number): Promise<void> {
|
|
await api.delete(`/meal-plans/${planId}/slots/${slotId}`)
|
|
},
|
|
|
|
async getShoppingList(planId: number): Promise<ShoppingList> {
|
|
const resp = await api.get<ShoppingList>(`/meal-plans/${planId}/shopping-list`)
|
|
return resp.data
|
|
},
|
|
|
|
async getPrepSession(planId: number): Promise<PrepSession> {
|
|
const resp = await api.get<PrepSession>(`/meal-plans/${planId}/prep-session`)
|
|
return resp.data
|
|
},
|
|
|
|
async updatePrepTask(planId: number, taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<PrepTask> {
|
|
const resp = await api.patch<PrepTask>(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data)
|
|
return resp.data
|
|
},
|
|
}
|
|
|
|
// ========== Orch Usage Types ==========
|
|
|
|
export interface OrchUsage {
|
|
calls_used: number
|
|
topup_calls: number
|
|
calls_total: number
|
|
period_start: string // ISO date YYYY-MM-DD
|
|
resets_on: string // ISO date YYYY-MM-DD
|
|
}
|
|
|
|
// ========== Browser Types ==========
|
|
|
|
export interface BrowserDomain {
|
|
id: string
|
|
label: string
|
|
}
|
|
|
|
export interface BrowserCategory {
|
|
category: string
|
|
recipe_count: number
|
|
has_subcategories: boolean
|
|
}
|
|
|
|
export interface BrowserSubcategory {
|
|
subcategory: string
|
|
recipe_count: number
|
|
}
|
|
|
|
export interface BrowserRecipe {
|
|
id: number
|
|
title: string
|
|
category: string | null
|
|
match_pct: number | null
|
|
}
|
|
|
|
export interface BrowserResult {
|
|
recipes: BrowserRecipe[]
|
|
total: number
|
|
page: number
|
|
}
|
|
|
|
// ========== Browser API ==========
|
|
|
|
export const browserAPI = {
|
|
async listDomains(): Promise<BrowserDomain[]> {
|
|
const response = await api.get('/recipes/browse/domains')
|
|
return response.data
|
|
},
|
|
async listCategories(domain: string): Promise<BrowserCategory[]> {
|
|
const response = await api.get(`/recipes/browse/${domain}`)
|
|
return response.data
|
|
},
|
|
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
|
const response = await api.get(
|
|
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
|
)
|
|
return response.data
|
|
},
|
|
async browse(domain: string, category: string, params?: {
|
|
page?: number
|
|
page_size?: number
|
|
pantry_items?: string
|
|
subcategory?: string
|
|
q?: string
|
|
sort?: string
|
|
}): Promise<BrowserResult> {
|
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// ── Shopping List ─────────────────────────────────────────────────────────────
|
|
|
|
export interface GroceryLink {
|
|
ingredient: string
|
|
retailer: string
|
|
url: string
|
|
}
|
|
|
|
export interface ShoppingItem {
|
|
id: number
|
|
name: string
|
|
quantity: number | null
|
|
unit: string | null
|
|
category: string | null
|
|
checked: boolean
|
|
notes: string | null
|
|
source: string
|
|
recipe_id: number | null
|
|
sort_order: number
|
|
created_at: string
|
|
updated_at: string
|
|
grocery_links: GroceryLink[]
|
|
}
|
|
|
|
export interface ShoppingItemCreate {
|
|
name: string
|
|
quantity?: number
|
|
unit?: string
|
|
category?: string
|
|
notes?: string
|
|
source?: string
|
|
recipe_id?: number
|
|
sort_order?: number
|
|
}
|
|
|
|
export interface ShoppingItemUpdate {
|
|
name?: string
|
|
quantity?: number
|
|
unit?: string
|
|
category?: string
|
|
checked?: boolean
|
|
notes?: string
|
|
sort_order?: number
|
|
}
|
|
|
|
export const shoppingAPI = {
|
|
list: (includeChecked = true) =>
|
|
api.get<ShoppingItem[]>('/shopping', { params: { include_checked: includeChecked } }).then(r => r.data),
|
|
|
|
add: (item: ShoppingItemCreate) =>
|
|
api.post<ShoppingItem>('/shopping', item).then(r => r.data),
|
|
|
|
addFromRecipe: (recipeId: number, includeCovered = false) =>
|
|
api.post<ShoppingItem[]>('/shopping/from-recipe', { recipe_id: recipeId, include_covered: includeCovered }).then(r => r.data),
|
|
|
|
update: (id: number, update: ShoppingItemUpdate) =>
|
|
api.patch<ShoppingItem>(`/shopping/${id}`, update).then(r => r.data),
|
|
|
|
remove: (id: number) =>
|
|
api.delete(`/shopping/${id}`),
|
|
|
|
clearChecked: () =>
|
|
api.delete('/shopping/checked'),
|
|
|
|
clearAll: () =>
|
|
api.delete('/shopping/all'),
|
|
|
|
confirmPurchase: (id: number, location = 'pantry', quantity?: number, unit?: string) =>
|
|
api.post(`/shopping/${id}/confirm`, { location, quantity, unit }).then(r => r.data),
|
|
}
|
|
|
|
// ── Orch Usage ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getOrchUsage(): Promise<OrchUsage | null> {
|
|
const resp = await api.get<OrchUsage | null>('/orch-usage')
|
|
return resp.data
|
|
}
|
|
|
|
// ── Session Bootstrap ─────────────────────────────────────────────────────────
|
|
|
|
export interface SessionInfo {
|
|
auth: 'local' | 'anon' | 'authed'
|
|
tier: string
|
|
has_byok: boolean
|
|
}
|
|
|
|
/** Call once on app load. Logs auth= + tier= server-side for analytics. */
|
|
export async function bootstrapSession(): Promise<SessionInfo | null> {
|
|
try {
|
|
const resp = await api.get<SessionInfo>('/session/bootstrap')
|
|
return resp.data
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export default api
|