kiwi/frontend/src/services/api.ts
pyr0ball 7498995092
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
feat(filters): split time filter into hands-on and total time (kiwi#52)
Adds max_active_min request field and backend filter. Active time uses
parse_time_effort().active_min (passive waits excluded). Recipes with
no parsed active time signal are not excluded (avoid hiding unlabelled
results). Total and active limits are AND'd when both set.

UI: two pill rows — "Hands-on time" (15/30/45/1hr) and "Total time"
(30m/1hr/90m/2hr/3hr/4+hr). Replaces single row capped at 90 min.
2026-04-27 16:03:27 -07:00

1281 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
needs_visual_capture: boolean
message: string
}
export interface LabelCaptureResult {
barcode: string
product_name: string | null
brand: string | null
serving_size_g: number | null
calories: number | null
fat_g: number | null
saturated_fat_g: number | null
carbs_g: number | null
sugar_g: number | null
fiber_g: number | null
protein_g: number | null
sodium_mg: number | null
ingredient_names: string[]
allergens: string[]
confidence: number
needs_review: boolean
}
export interface LabelConfirmRequest {
barcode: string
product_name?: string | null
brand?: string | null
serving_size_g?: number | null
calories?: number | null
fat_g?: number | null
saturated_fat_g?: number | null
carbs_g?: number | null
sugar_g?: number | null
fiber_g?: number | null
protein_g?: number | null
sodium_mg?: number | null
ingredient_names?: string[]
allergens?: string[]
confidence?: number
location?: string
quantity?: number
auto_add?: boolean
}
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
},
/**
* Upload a nutrition label photo for an unenriched barcode (paid tier).
* Returns extracted fields + confidence score for user review.
*/
async captureLabelPhoto(
file: File,
barcode: string
): Promise<LabelCaptureResult> {
const formData = new FormData()
formData.append('file', file)
formData.append('barcode', barcode)
const response = await api.post('/inventory/scan/label-capture', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // vision inference can take ~510s
})
return response.data
},
/**
* Confirm a user-reviewed label extraction and save to the local cache.
*/
async confirmLabelCapture(data: LabelConfirmRequest): Promise<{ ok: boolean; product_id?: number; inventory_item_id?: number; message: string }> {
const response = await api.post('/inventory/scan/label-confirm', 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
time_effort: TimeEffortProfile | 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 StreamTokenResponse {
stream_url: string
token: string
expires_in_s: number
}
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
export interface RecipeJobStatus {
job_id: string
status: RecipeJobStatusValue
result: RecipeResult | null
error: string | null
}
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[]
exclude_ingredients: string[]
shopping_mode: boolean
pantry_match_only: boolean
complexity_filter: string | null
max_time_min: number | null
max_total_min: number | null
max_active_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
},
/** Submit an async job for L3/L4 generation. Returns job_id + initial status. */
async suggestAsync(req: RecipeRequest): Promise<{ job_id: string; status: string }> {
const response = await api.post('/recipes/suggest', req, { params: { async: 'true' }, timeout: 15000 })
return response.data
},
/** Poll an async job. Returns the full status including result once done. */
async pollJob(jobId: string): Promise<RecipeJobStatus> {
const response = await api.get(`/recipes/jobs/${jobId}`, { timeout: 10000 })
return response.data
},
async getRecipe(id: number): Promise<RecipeSuggestion> {
const response = await api.get(`/recipes/${id}`)
return response.data
},
async getLeftovers(id: number): Promise<{ fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }> {
const response = await api.post(`/recipes/${id}/leftovers`)
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
},
/** Issue a one-time stream token for LLM recipe generation (Paid tier / BYOK only). */
async getRecipeStreamToken(params: {
level: 3 | 4
wildcard_confirmed?: boolean
}): Promise<StreamTokenResponse> {
const response = await api.post('/recipes/stream-token', {
level: params.level,
wildcard_confirmed: params.wildcard_confirmed ?? false,
})
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}`)
},
async classifyStyle(recipe_id: number): Promise<string[]> {
const response = await api.post(`/recipes/saved/${recipe_id}/classify-style`)
return response.data.suggested_tags
},
}
// --- 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
}
// ── Time & Effort types ───────────────────────────────────────────────────
export interface StepAnalysis {
is_passive: boolean
detected_minutes: number | null
}
export interface TimeEffortProfile {
active_min: number
passive_min: number
total_min: number
effort_label: 'quick' | 'moderate' | 'involved'
equipment: string[]
step_analyses: StepAnalysis[]
}
export interface BrowserRecipe {
id: number
title: string
category: string | null
match_pct: number | null
active_min: number | null
passive_min: 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
required_ingredient?: string
}): Promise<BrowserResult> {
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
return response.data
},
async submitRecipeTag(body: {
recipe_id: number
domain: string
category: string
subcategory: string | null
pseudonym: string
}): Promise<void> {
await api.post('/recipes/community-tags', body)
},
async upvoteRecipeTag(tagId: number, pseudonym: string): Promise<void> {
await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } })
},
async listRecipeTags(recipeId: number): Promise<Array<{
id: number; domain: string; category: string; subcategory: string | null;
pseudonym: string; upvotes: number; accepted: boolean
}>> {
const response = await api.get(`/recipes/community-tags/${recipeId}`)
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
}
}
// ========== Sensory Preferences Types ==========
export type TextureTag = 'mushy' | 'slimy' | 'crunchy' | 'chewy' | 'creamy' | 'chunky'
export type SmellLevel = 'mild' | 'aromatic' | 'pungent' | 'fermented' | null
export type NoiseLevel = 'quiet' | 'moderate' | 'loud' | 'very_loud' | null
export interface SensoryPreferences {
avoid_textures: TextureTag[]
max_smell: SmellLevel
max_noise: NoiseLevel
}
export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
avoid_textures: [],
max_smell: null,
max_noise: null,
}
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
export interface ScannedIngredient {
name: string
qty: string | null
unit: string | null
raw: string | null
in_pantry: boolean
}
export interface ScannedRecipe {
title: string | null
subtitle: string | null
servings: string | null
cook_time: string | null
source_note: string | null
ingredients: ScannedIngredient[]
steps: string[]
notes: string | null
tags: string[]
pantry_match_pct: number
confidence: 'high' | 'medium' | 'low'
warnings: string[]
}
export interface UserRecipe {
id: number
title: string
subtitle: string | null
servings: string | null
cook_time: string | null
source_note: string | null
ingredients: ScannedIngredient[]
steps: string[]
notes: string | null
tags: string[]
source: string
pantry_match_pct: number | null
created_at: string
}
export const recipeScanAPI = {
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
scan(files: File[]): Promise<ScannedRecipe> {
const form = new FormData()
files.forEach((f) => form.append('files', f))
return api.post('/recipes/scan', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000, // VLM can be slow on first call
}).then((r) => r.data)
},
/** Save a reviewed/edited scanned recipe to user_recipes. */
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
},
/** List all user-created recipes (scan + manual). */
listUserRecipes(): Promise<UserRecipe[]> {
return api.get('/recipes/user').then((r) => r.data)
},
/** Get a single user recipe by ID. */
getUserRecipe(id: number): Promise<UserRecipe> {
return api.get(`/recipes/user/${id}`).then((r) => r.data)
},
/** Delete a user recipe. */
deleteUserRecipe(id: number): Promise<void> {
return api.delete(`/recipes/user/${id}`).then(() => undefined)
},
}
export default api