From d9ba2276575ea88763f33f3cfb10cf833ea6d685 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 3 Sep 2026 19:08:46 +0530 Subject: [PATCH] Ingredients as many-to-many relations + VS Code/Antigravity launch.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new ingredients collection (uid, name, emoji icon, diet_class) synced by update-menu.mjs; menu_items.ingredients is an M2M relation — the script maps data-file uids to fresh PB record ids - diet select removed: the green/red FSSAI badge is derived (non-veg when any linked ingredient is non-veg); 164 items tagged (11 ingredients) - menu cards render ingredient chips (icon + name) under the description - migration 1788500200_menu_ingredients.js (idempotent create — app.save returns nil on success in the JSVM, so don't test its return value) - .vscode/launch.json: PocketBase + Vite dev, build+preview, menu sync, full-stack compound (dev PB on :8091 — 8090 is taken on this host) --- README.md | 23 +- app/src/components/menu-browser.tsx | 54 ++- app/src/data/menu-data.mjs | 347 ++++++++++--------- app/src/lib/data.ts | 6 +- app/src/lib/pb.ts | 7 +- app/src/lib/types.ts | 16 +- app/src/routes/menu.tsx | 14 +- pb/migrations/1788500200_menu_ingredients.js | 125 +++++++ scripts/update-menu.mjs | 35 +- 9 files changed, 435 insertions(+), 192 deletions(-) create mode 100644 pb/migrations/1788500200_menu_ingredients.js diff --git a/README.md b/README.md index cb39967..b3dad29 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,9 @@ PB_SUPERUSER_PASSWORD=... \ node scripts/update-menu.mjs ``` -The script **replaces** all `menu_categories` / `menu_items` records (deletes old, inserts -new) and touches nothing else. +The script **replaces** all `menu_categories` / `ingredients` / `menu_items` records (deletes +old, inserts new; item ingredient uids are mapped to the fresh ingredient record ids) and +touches nothing else. ### `menu_items` schema (POS-oriented) @@ -176,6 +177,11 @@ new) and touches nothing else. - `description` - `price` — number, rupees with optional paise after the decimal point (449 = ₹449, 449.5 = ₹449.50) +- `ingredients` — many-to-many relation to the `ingredients` collection (Nuts, Dairy, + Gluten, Soy, Mushroom, Egg, Chicken, Fish, Prawns, Pork, Mutton). Each ingredient has a + name, an emoji `icon` (rendered as chips on menu cards) and a `diet_class` + (`veg`/`nonveg`); an item's green/red FSSAI badge is **derived** — it shows non-veg when + any linked ingredient is non-veg - `diet` — select: `veg` / `nonveg` / `mixed` (a bool can't be null in PocketBase, so mixed items like Caesar Salad use `mixed`; the site renders no badge for it) - `pairing` — optional beer pairing @@ -188,6 +194,19 @@ new) and touches nothing else. Admin edits to menu records are overwritten by the next sync — treat `menu-data.mjs` as the menu's source of truth. +## Local development (IDE) + +`.vscode/launch.json` has run configurations for VS Code / Antigravity: + +- **GreatBear (full stack)** — compound: PocketBase (:8091) + Vite dev server (:3000) +- `PocketBase (dev, :8091)` — serves `./pb_data` with `./pb/migrations` +- `Site (Vite dev, :3000)` — the website against the local PocketBase +- `Site (production build + preview)` — builds `.output` and previews it +- `Sync menu into PocketBase` — runs `scripts/update-menu.mjs` against the local PB + +Dev PocketBase runs on **8091** (8090 is occupied by another service on this machine). +First-time setup is in the comments at the top of `launch.json`. + ## Container environment | Variable | Default | Purpose | diff --git a/app/src/components/menu-browser.tsx b/app/src/components/menu-browser.tsx index 78868cd..ec72de1 100644 --- a/app/src/components/menu-browser.tsx +++ b/app/src/components/menu-browser.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import type { MenuCategory, MenuItem } from '~/lib/types' +import type { Ingredient, MenuCategory, MenuItem } from '~/lib/types' const CATEGORY_ICONS: Record = { 'brew-bites': 'tapas', @@ -18,9 +18,11 @@ const CATEGORY_ICONS: Record = { export function MenuBrowser({ categories, items, + ingredientIndex, }: { categories: MenuCategory[] items: MenuItem[] + ingredientIndex: Map }) { const [active, setActive] = React.useState('all') @@ -54,7 +56,7 @@ export function MenuBrowser({
{visible.map((item) => ( - + ))}
@@ -67,9 +69,27 @@ function formatPrice(price: number): string { return Number.isInteger(price) ? String(price) : price.toFixed(2) } -function MenuCard({ item }: { item: MenuItem }) { +function resolveIngredients( + item: MenuItem, + ingredientIndex: Map, +): Ingredient[] { + return (item.ingredients ?? []).flatMap((key) => { + const ing = ingredientIndex.get(key) + return ing ? [ing] : [] + }) +} + +function MenuCard({ + item, + ingredientIndex, +}: { + item: MenuItem + ingredientIndex: Map +}) { const icon = CATEGORY_ICONS[item.category] ?? 'restaurant' const showFooter = Boolean(item.pairing || item.tag) + const ingredients = resolveIngredients(item, ingredientIndex) + const isVeg = !ingredients.some((ing) => ing.diet_class === 'nonveg') return (
@@ -78,13 +98,13 @@ function MenuCard({ item }: { item: MenuItem }) { className="relative h-64 w-full bg-cover bg-center" style={{ backgroundImage: `url('${item.image}')` }} > - +
) : (
{icon}
- +
)} @@ -105,6 +125,7 @@ function MenuCard({ item }: { item: MenuItem }) { {item.description ? (

{item.description}

) : null} + {ingredients.length > 0 ? : null} {showFooter ? ( @@ -136,9 +157,8 @@ function MenuCard({ item }: { item: MenuItem }) { ) } -function ItemBadges({ item }: { item: MenuItem }) { - if (item.diet !== 'veg' && item.diet !== 'nonveg') return null - return +function ItemBadges({ isVeg }: { isVeg: boolean }) { + return } /** FSSAI-style dietary mark: green square for veg, red for non-veg. */ @@ -155,6 +175,24 @@ function DietBadge({ isVeg }: { isVeg: boolean }) { ) } +/** Ingredient icons (emoji) with names on hover. */ +function IngredientChips({ ingredients }: { ingredients: Ingredient[] }) { + return ( +
+ {ingredients.map((ing) => ( + + + {ing.name} + + ))} +
+ ) +} + function FilterTab({ label, active, diff --git a/app/src/data/menu-data.mjs b/app/src/data/menu-data.mjs index edaf435..a035d97 100644 --- a/app/src/data/menu-data.mjs +++ b/app/src/data/menu-data.mjs @@ -6,13 +6,16 @@ * edit this file, commit, then run scripts/update-menu.mjs to refresh the * live PocketBase data. * - * Schema notes (v3, POS-friendly): + * Schema notes (v4, POS-friendly): * - Variants are separate products whenever their price differs * ("Penne Arrabiata" + variant "Veg" 589 / "Chicken" 639). * - Same-price choice lists (flavours, sauces) stay on one item in `tag`. * - price: number, rupees with optional paise after the decimal point * (449 = ₹449, 449.5 = ₹449.50). - * - diet: 'veg' | 'nonveg' | 'mixed' (PB bools can't be null, so a select is used). + * - ingredients: many-to-many with the INGREDIENTS list below (uids here; + * the sync script maps them to PocketBase record ids). The site derives + * each item's veg/non-veg badge from its ingredients and renders each + * ingredient's icon. * - sku: stable GB-- code for POS mapping. * - tax_category: default "food-5-gst" — adjust per item if the POS needs it. * - available: false hides an item (86'd) without deleting it. @@ -32,6 +35,25 @@ export const MENU_CATEGORIES = [ { uid: 'cat-pizzas', slug: 'pizzas', name: 'Pizzas', sort: 11 }, ] +/** + * Ingredients / dietary markers. `icon` is an emoji shown on menu cards; + * `diet_class` drives the veg / non-veg badge (an item is non-veg when any + * of its ingredients is non-veg). + */ +export const INGREDIENTS = [ + { uid: 'nuts', name: 'Nuts', icon: '🥜', diet_class: 'veg', sort: 1 }, + { uid: 'dairy', name: 'Dairy', icon: '🥛', diet_class: 'veg', sort: 2 }, + { uid: 'gluten', name: 'Gluten', icon: '🌾', diet_class: 'veg', sort: 3 }, + { uid: 'soy', name: 'Soy', icon: '🌱', diet_class: 'veg', sort: 4 }, + { uid: 'mushroom', name: 'Mushroom', icon: '🍄', diet_class: 'veg', sort: 5 }, + { uid: 'egg', name: 'Egg', icon: '🥚', diet_class: 'nonveg', sort: 6 }, + { uid: 'chicken', name: 'Chicken', icon: '🍗', diet_class: 'nonveg', sort: 7 }, + { uid: 'fish', name: 'Fish', icon: '🐟', diet_class: 'nonveg', sort: 8 }, + { uid: 'prawns', name: 'Prawns', icon: '🍤', diet_class: 'nonveg', sort: 9 }, + { uid: 'pork', name: 'Pork', icon: '🥓', diet_class: 'nonveg', sort: 10 }, + { uid: 'mutton', name: 'Mutton', icon: '🍖', diet_class: 'nonveg', sort: 11 }, +] + const CAT_CODES = { 'brew-bites': 'BRB', 'sharing-plates': 'SHP', @@ -49,7 +71,7 @@ const CAT_CODES = { let sortCounter = 0 const skuCounters = {} -function item(category, name, price, { variant = '', desc = '', diet = 'mixed', tag = '', pairing = '' } = {}) { +function item(category, name, price, { variant = '', desc = '', ing = [], tag = '', pairing = '' } = {}) { sortCounter += 1 skuCounters[category] = (skuCounters[category] ?? 0) + 1 const slugSource = variant ? `${name} ${variant}` : name @@ -64,7 +86,7 @@ function item(category, name, price, { variant = '', desc = '', diet = 'mixed', variant, description: desc, price, - diet, + ingredients: ing, pairing, tag, image: '', @@ -78,687 +100,678 @@ function item(category, name, price, { variant = '', desc = '', diet = 'mixed', export const MENU_ITEMS = [ // ---------------------------------------------------------------- Brew Bites item('brew-bites', 'Chana Chaat', 399, { - diet: 'veg', desc: 'Cooked white chickpeas tossed with onion, tomato, herbs, sweet & spicy chutney & aromatic spices', }), item('brew-bites', 'French Fries', 389, { - diet: 'veg', tag: 'Classic / Gunpowder / Peri-Peri', }), - item('brew-bites', 'Garlic Bread', 239, { variant: 'Plain', diet: 'veg', desc: 'House bake' }), - item('brew-bites', 'Garlic Bread', 399, { variant: 'With Cheese', diet: 'veg', desc: 'House bake' }), - item('brew-bites', 'Onion Rings', 419, { diet: 'veg', desc: 'Deep fried onion rings + thousand island' }), + item('brew-bites', 'Garlic Bread', 239, { variant: 'Plain', desc: 'House bake', ing: ['gluten', 'dairy'] }), + item('brew-bites', 'Garlic Bread', 399, { variant: 'With Cheese', desc: 'House bake', ing: ['gluten', 'dairy'] }), + item('brew-bites', 'Onion Rings', 419, { desc: 'Deep fried onion rings + thousand island', ing: ['gluten', 'dairy'] }), item('brew-bites', 'Peanut Papad Masala', 389, { - diet: 'veg', desc: 'Roasted peanuts tossed with onion, tomato, lemon, fresh coriander & aromatic spices', + ing: ['nuts'], }), item('brew-bites', 'Edamame', 389, { - diet: 'veg', tag: 'Steamed / Spicy', desc: 'Edamame in the pod steamed & salted / tempered with garlic & chili', + ing: ['soy'], }), item('brew-bites', 'Chicken & Cheese Bites', 459, { - diet: 'nonveg', desc: 'Chicken minced and cheese mix, fried to perfection + chipotle dip', + ing: ['chicken', 'dairy', 'gluten'], }), // ------------------------------------------------------------ Sharing Plates item('sharing-plates', 'Hummus Pita Platter', 449, { variant: 'Veg', - diet: 'veg', desc: 'Classic, roasted bell pepper & black beans-cumin hummus, pita, sesame naan & za’atar crostini + arabic pickle', + ing: ['gluten'], }), item('sharing-plates', 'Hummus Pita Platter', 549, { variant: 'Chicken', - diet: 'nonveg', desc: 'Classic, roasted bell pepper & black beans-cumin hummus, pita, sesame naan & za’atar crostini + arabic pickle', + ing: ['gluten', 'chicken'], }), item('sharing-plates', 'Ultimate Nachos', 599, { variant: 'Veg', - diet: 'veg', desc: 'Corn tortilla, beans sauce, pico-de-gallo, cheese sauce, tomato salsa & sour cream', + ing: ['dairy', 'gluten'], }), item('sharing-plates', 'Ultimate Nachos', 699, { variant: 'Chicken', - diet: 'nonveg', desc: 'Corn tortilla, beans sauce, pico-de-gallo, cheese sauce, tomato salsa & sour cream', + ing: ['dairy', 'gluten', 'chicken'], }), // ------------------------------------------------------------------- Salads item('salads', 'Avocado & Egg Salad', 509, { - diet: 'nonveg', desc: 'Mixed greens, green beans, cherry tomato, olives, pumpkin seeds + malta vinaigrette', + ing: ['egg'], }), item('salads', 'Crispy Lotus Stem & Chickpea Salad', 429, { - diet: 'veg', desc: 'Lettuce, bell peppers, corns, chickpeas, crispy lotus stem + citrus vinaigrette', }), item('salads', 'Caesar Salad', 449, { tag: 'Veg / Chicken', desc: 'Iceberg, romaine, parmesan shavings, croutons + veg caesar dressing', + ing: ['dairy', 'egg', 'gluten', 'chicken'], }), item('salads', 'Chicken Tikka Niçoise Salad', 499, { - diet: 'nonveg', desc: 'Mixed greens, pulled chicken tikka, boiled egg, potato cubes, green beans, olives + lemon vinaigrette', + ing: ['chicken', 'egg'], }), item('salads', 'Quinoa Health Freak Salad', 499, { variant: 'Veg', - diet: 'veg', desc: 'Quinoa, lettuce, cucumber, cherry tomato, olives, broccoli + apple cider dressing', }), item('salads', 'Quinoa Health Freak Salad', 529, { variant: 'Chicken', - diet: 'nonveg', desc: 'Quinoa, lettuce, cucumber, cherry tomato, olives, broccoli + apple cider dressing', + ing: ['chicken'], }), item('salads', 'Roasted Beetroot & Walnut Salad', 449, { - diet: 'veg', desc: 'Mixed lettuce, roasted beet, broccoli, orange segments, cherry tomatoes & feta + balsamic dressing', + ing: ['nuts', 'dairy'], }), item('salads', 'Warm Vegetable Salad', 469, { - diet: 'veg', desc: 'Warm exotic vegetables tossed with mustard dressing, topped with pumpkin seeds & roasted almond', + ing: ['nuts'], }), // ----------------------------------------------------------------- Platters item('platters', 'Chelo Kebab Platter', 1069, { - diet: 'nonveg', desc: 'Basil chicken seekh, za’atar mutton seekh, chicken shish taouk, sumac chicken breast, saffron rice + tzatziki', + ing: ['chicken', 'mutton', 'dairy'], }), item('platters', 'Chicken Tikka Platter', 1159, { - diet: 'nonveg', desc: 'Herb-pepper, malai, parmesan chicken, angara, lehsuni', + ing: ['chicken', 'dairy'], }), item('platters', 'Mezze Platter', 899, { variant: 'Veg', - diet: 'veg', desc: 'Pan seared cottage cheese, falafel, spinach-ricotta roll, tabbouleh, pita bread, arabic pickle + hummus, labneh, chilli dip', + ing: ['dairy', 'gluten'], }), item('platters', 'Mezze Platter', 1159, { variant: 'Non-Veg', - diet: 'nonveg', desc: 'Cajun fish, peri-peri chicken, lamb roll, chicken kafta, arabic pickle, pita bread + hummus, labneh, chilli dip', + ing: ['fish', 'chicken', 'mutton', 'dairy', 'gluten'], }), item('platters', 'Tandoori Veg Platter', 999, { - diet: 'veg', desc: 'Achari paneer tikka, hari mirch paneer tikka, tandoori khumb, roasted pineapple, tandoori soya chaap, quinoa veg seekh, sprouted moong kebab', + ing: ['dairy', 'soy', 'mushroom'], }), item('platters', 'Seafood Fritters Platter', 1299, { - diet: 'nonveg', desc: 'Batter fried prawns, ajwain fish fry, fish fingers, mixed seafood fritters + tartar sauce, mint mayo', + ing: ['prawns', 'fish', 'egg', 'gluten'], }), item('platters', 'Tandoori Meat Platter', 1299, { - diet: 'nonveg', desc: 'Tandoori prawns, tandoori fish tikka, mutton seekh, tandoori chicken, angara chicken tikka, parmesan chicken tikka, chicken seekh', + ing: ['prawns', 'fish', 'mutton', 'chicken', 'dairy'], }), item('platters', 'Sizzler', 699, { variant: 'Cottage Cheese', - diet: 'veg', desc: 'Choose your starch (fried rice / noodles) & your sauce (schezwan / sweet n sour); served with soya sesame vegetables, grilled pineapple slice & fries', + ing: ['dairy', 'soy', 'gluten'], }), item('platters', 'Sizzler', 699, { variant: 'Manchurian', - diet: 'veg', desc: 'Choose your starch (fried rice / noodles) & your sauce (schezwan / sweet n sour); served with soya sesame vegetables, grilled pineapple slice & fries', + ing: ['soy', 'gluten'], }), item('platters', 'Sizzler', 799, { variant: 'Chicken', - diet: 'nonveg', desc: 'Choose your starch (fried rice / noodles) & your sauce (schezwan / sweet n sour); served with soya sesame vegetables, grilled pineapple slice & fries', + ing: ['chicken', 'soy', 'gluten'], }), // ------------------------------------------------- International Appetizers item('international-appetizers', 'Apple & Avocado Salsa', 439, { - diet: 'veg', desc: 'Granny smith apple, khakhra crisp and melba toast', + ing: ['gluten'], }), item('international-appetizers', 'Mushroom Pepper Fry', 549, { - diet: 'veg', desc: 'Mushroom cooked with black pepper fry masala + Malabari parotta', + ing: ['mushroom', 'gluten'], }), item('international-appetizers', 'Corn Salt N Pepper', 519, { - diet: 'veg', desc: 'Crispy fried corn, onions, spring onions', }), item('international-appetizers', 'Chilli Paneer / Chilli Mushroom', 549, { - diet: 'veg', tag: 'Paneer / Mushroom', desc: 'Crispy fried cubes, onion, capsicum, bell peppers + chilli paste', + ing: ['dairy', 'mushroom', 'soy'], }), item('international-appetizers', 'Cottage Cheese Quesadilla', 579, { - diet: 'veg', desc: 'Flour tortilla, mix cheese, beans + tomato salsa, sour cream', + ing: ['dairy', 'gluten'], }), item('international-appetizers', 'Falafel', 439, { - diet: 'veg', desc: 'Arabic pickle, chickpea patties + hummus & pita', + ing: ['gluten'], }), item('international-appetizers', 'Honey Chilly Cauliflower / Potato', 449, { - diet: 'veg', tag: 'Cauliflower / Potato', desc: 'Crispy fried cauliflower/potato, sesame seeds, honey chili sauce', + ing: ['gluten'], }), item('international-appetizers', 'Korean Fried Paneer', 549, { - diet: 'veg', desc: 'Crispy fried paneer tossed in umami Korean sauce', + ing: ['dairy', 'soy', 'gluten'], }), item('international-appetizers', 'Peri-Peri Quinoa Popcorn', 449, { - diet: 'veg', desc: 'Paneer cubes, quinoa crusted + peri peri dust and roasted garlic aioli', + ing: ['dairy', 'egg'], }), item('international-appetizers', 'Exotic Vegetables', 439, { - diet: 'veg', desc: 'Stir fry vegetables with garlic and butter', + ing: ['dairy', 'soy'], }), item('international-appetizers', 'Thai Chilli Crispy Lotus Stems', 479, { - diet: 'veg', desc: 'Crispy fried lotus stems in chilli sauce, spring onion & roasted sesame seeds', }), item('international-appetizers', 'Veg Manchurian', 449, { - diet: 'veg', desc: 'Fried vegetable balls in sweet & sour, hot manchurian sauce', + ing: ['soy', 'gluten'], }), item('international-appetizers', 'Veg Cigar Roll', 449, { - diet: 'veg', desc: 'Crispy fried cigar rolls stuffed with cheese & fresh vegetables, pickled salad & spicy remoulade', + ing: ['dairy', 'gluten'], }), item('international-appetizers', 'Boneless Chicken Wings', 629, { - diet: 'nonveg', desc: 'Juicy crispy boneless chicken breast + classic hot sauce, ranch dip', + ing: ['chicken', 'gluten', 'dairy'], }), item('international-appetizers', 'BBQ Pork Ribs', 899, { - diet: 'nonveg', desc: 'Tender slow cooked BBQ pork ribs served with crispy french fries', + ing: ['pork'], }), item('international-appetizers', 'Chicken Quesadilla', 719, { - diet: 'nonveg', desc: 'Flour tortillas filled with chicken & cheese, mexican rice + pico de gallo, sour cream', + ing: ['chicken', 'dairy', 'gluten'], }), item('international-appetizers', 'Chilli Chicken', 639, { - diet: 'nonveg', desc: 'Crispy fried boneless chicken, onion, bell peppers + chilli paste', + ing: ['chicken', 'soy'], }), item('international-appetizers', 'Chicken Wings', 599, { - diet: 'nonveg', tag: 'BBQ / Hot Sauce / Peri-Peri / Grandma Style', desc: 'Crispy fried to perfection', + ing: ['chicken', 'gluten'], }), item('international-appetizers', 'Chilly Garlic Prawns', 889, { - diet: 'nonveg', desc: 'Spicy prawns tossed in hot garlic sauce', + ing: ['prawns', 'soy'], }), item('international-appetizers', 'Fish and Chips', 799, { variant: 'Basa', - diet: 'nonveg', desc: 'Crispy battered fish served with golden fries + egg tartar sauce, ponzu dip', + ing: ['fish', 'gluten', 'egg'], }), item('international-appetizers', 'Fish and Chips', 949, { variant: 'Sole', - diet: 'nonveg', desc: 'Crispy battered fish served with golden fries + egg tartar sauce, ponzu dip', + ing: ['fish', 'gluten', 'egg'], }), item('international-appetizers', 'Korean Fried Chicken', 639, { - diet: 'nonveg', desc: 'Crispy fried chicken tossed in umami Korean sauce', + ing: ['chicken', 'soy', 'gluten'], }), item('international-appetizers', 'Moroccan Grilled Chicken', 659, { - diet: 'nonveg', desc: 'Arabic spice, hung curd, served with grilled veggies + creamy labneh', + ing: ['chicken', 'dairy'], }), item('international-appetizers', 'Kaffir Lime-Coconut Fish Fillet', 799, { variant: 'Basa', - diet: 'nonveg', desc: 'Thai flavour infused fish with spicy coconut curry', + ing: ['fish'], }), item('international-appetizers', 'Kaffir Lime-Coconut Fish Fillet', 949, { variant: 'Sole', - diet: 'nonveg', desc: 'Thai flavour infused fish with spicy coconut curry', + ing: ['fish'], }), item('international-appetizers', 'Schezwan Chicken Lollipop', 599, { - diet: 'nonveg', desc: 'Crispy oriental style chicken wings tossed in fiery schezwan sauce', + ing: ['chicken', 'soy'], }), item('international-appetizers', 'Spicy Chicken Strips', 599, { - diet: 'nonveg', desc: 'Spicy strips + sweet chilli sauce', + ing: ['chicken', 'gluten'], }), // -------------------------------------------------------- Indian Appetizers item('indian-appetizers', 'Quinoa Seekh Kebab', 429, { - diet: 'veg', desc: 'Minced vegetables, paneer & quinoa seekh kebab + mint sauce', + ing: ['dairy'], }), item('indian-appetizers', 'Sprouted Moong Kebab', 429, { - diet: 'veg', desc: 'Green sprouted moong, fresh coriander, spices & shallow fry + mint sauce', }), item('indian-appetizers', 'Dahi Kebab', 449, { - diet: 'veg', desc: 'Hung curd, onion, bell pepper, coriander seeds, seasoning', + ing: ['dairy'], }), item('indian-appetizers', 'Prune Stuffed Paneer Tikka', 559, { - diet: 'veg', desc: 'Khata meeta prunes stuffed paneer, marinated with tandoori masala + mint sauce', + ing: ['dairy'], }), item('indian-appetizers', 'Molten Beetroot & Peanut Kebab', 449, { - diet: 'veg', desc: 'Crumb fried beetroot kebab stuffed with cheese + scallion cream', + ing: ['nuts', 'dairy'], }), item('indian-appetizers', 'Paneer Tikka', 549, { - diet: 'veg', tag: 'Malai / Hari Mirch / Angara', desc: 'Paneer marinated with spices, cooked in tandoor, served hot + mint sauce', + ing: ['dairy'], }), item('indian-appetizers', 'Sweet Potato & Goat Cheese Tikki', 449, { - diet: 'veg', desc: 'Cranberry-sesame chutney, red chilli-green apple salsa', + ing: ['dairy'], }), item('indian-appetizers', 'Roasted Pineapple', 449, { - diet: 'veg', desc: 'Roasted in clay oven with Indian spices + mint sauce', }), item('indian-appetizers', 'Tandoori Khumb', 529, { - diet: 'veg', desc: 'Spicy mushrooms + mint sauce', + ing: ['mushroom', 'dairy'], }), item('indian-appetizers', 'Tandoori Soya Chaap', 459, { - diet: 'veg', tag: 'Malai / Angara', desc: 'Soya chaap cooked in clay oven + mint sauce', + ing: ['soy', 'dairy'], }), item('indian-appetizers', 'Maharashtrian Thecha Paneer', 549, { - diet: 'veg', desc: 'Green chili thecha tossed paneer cubes + Malabari paratha', + ing: ['dairy', 'gluten'], }), item('indian-appetizers', 'Amritsari Fish Fry', 729, { variant: 'Basa', - diet: 'nonveg', desc: 'Gram flour batter fried fish + mint chutney', + ing: ['fish'], }), item('indian-appetizers', 'Amritsari Fish Fry', 869, { variant: 'Sole', - diet: 'nonveg', desc: 'Gram flour batter fried fish + mint chutney', + ing: ['fish'], }), item('indian-appetizers', 'Chicken Tikka', 639, { - diet: 'nonveg', tag: 'Malai / Parmesan / Lehsuni / Angara / Herb-Pepper', desc: 'Marinated chicken, cooked in tandoor — choose your flavour', + ing: ['chicken', 'dairy'], }), item('indian-appetizers', 'Chicken Seekh', 549, { - diet: 'nonveg', desc: 'Marinated minced chicken, Indian herbs, spices + mint sauce', + ing: ['chicken'], }), item('indian-appetizers', 'Fish Tikka', 729, { variant: 'Basa', - diet: 'nonveg', tag: 'Angara / Kasundi', desc: 'Marinated fish cooked in clay oven + mint sauce', + ing: ['fish', 'dairy'], }), item('indian-appetizers', 'Fish Tikka', 869, { variant: 'Sole', - diet: 'nonveg', tag: 'Angara / Kasundi', desc: 'Marinated fish cooked in clay oven + mint sauce', + ing: ['fish', 'dairy'], }), item('indian-appetizers', 'Grilled Tawa Fish', 729, { variant: 'Basa', - diet: 'nonveg', desc: 'Fish marinated in Indian spices, pan grilled to perfection + mint sauce', + ing: ['fish'], }), item('indian-appetizers', 'Grilled Tawa Fish', 869, { variant: 'Sole', - diet: 'nonveg', desc: 'Fish marinated in Indian spices, pan grilled to perfection + mint sauce', + ing: ['fish'], }), item('indian-appetizers', 'Mutton Chapli Kebab', 759, { - diet: 'nonveg', desc: 'Minced mutton, chapli masala, cooked to perfection + rogan josh jus', + ing: ['mutton'], }), item('indian-appetizers', 'Mutton Seekh Kebab', 729, { - diet: 'nonveg', desc: 'Marinated minced lamb seasoned with Indian herbs & spices', + ing: ['mutton'], }), item('indian-appetizers', 'Tandoori Chicken', 559, { variant: 'Half', - diet: 'nonveg', desc: 'Chicken on the bone in cultured yogurt and spices', + ing: ['chicken', 'dairy'], }), item('indian-appetizers', 'Tandoori Chicken', 889, { variant: 'Full', - diet: 'nonveg', desc: 'Chicken on the bone in cultured yogurt and spices', + ing: ['chicken', 'dairy'], }), item('indian-appetizers', 'Tandoori Wings', 579, { - diet: 'nonveg', desc: 'Chicken wings in cultured yogurt and spices', + ing: ['chicken', 'dairy'], }), item('indian-appetizers', 'Prawns Masala', 949, { - diet: 'nonveg', desc: 'Spicy onion, tomato masala with curry leaves & mustard, served with uttapam', + ing: ['prawns', 'gluten'], }), item('indian-appetizers', 'Tawa Masala', 549, { variant: 'Chicken', - diet: 'nonveg', desc: 'Punjabi style chicken on bone, tawa masala & ground spices', + ing: ['chicken'], }), item('indian-appetizers', 'Tawa Masala', 779, { variant: 'Mutton', - diet: 'nonveg', desc: 'Punjabi style mutton on bone, tawa masala & ground spices', + ing: ['mutton'], }), item('indian-appetizers', 'Nimbu Hari Mirch ki Tangari', 579, { - diet: 'nonveg', desc: 'Chicken drumstick, spicy hari mirch nimbu marinated, cooked to perfection + mint sauce', + ing: ['chicken'], }), // -------------------------------------------------------------- Main Course item('main-course', 'Cottage Cheese Steak', 629, { - diet: 'veg', desc: 'Herb quinoa, creamy spinach-corn, sautéed vegetables + roasted pepper coulis', + ing: ['dairy'], }), item('main-course', 'Cajun Grilled Fish', 769, { variant: 'Basa', - diet: 'nonveg', desc: 'Quinoa, sautéed vegetables, guacamole', + ing: ['fish'], }), item('main-course', 'Cajun Grilled Fish', 899, { variant: 'Sole', - diet: 'nonveg', desc: 'Quinoa, sautéed vegetables, guacamole', + ing: ['fish'], }), item('main-course', 'Roasted Garlic & Basil Chicken Thigh', 699, { - diet: 'nonveg', desc: 'Garlic & basil chicken thigh, brown onion pilaf, butter broccoli & spicy pepper coulis', + ing: ['chicken', 'dairy'], }), item('main-course', 'Grilled Chicken Breast', 699, { - diet: 'nonveg', tag: 'Classic Chicken Jus / Hot Peri Peri', desc: 'Mash potato, sautéed vegetables', + ing: ['chicken', 'dairy'], }), item('main-course', 'Penne Arrabiata', 589, { variant: 'Veg', - diet: 'veg', desc: 'Pasta with exotic vegetables, basil, chilli flakes, chunky tomatoes + garlic bread', + ing: ['gluten', 'dairy'], }), item('main-course', 'Penne Arrabiata', 639, { variant: 'Chicken', - diet: 'nonveg', desc: 'Pasta with exotic vegetables, basil, chilli flakes, chunky tomatoes + garlic bread', + ing: ['gluten', 'dairy', 'chicken'], }), item('main-course', 'Penne Alfredo', 599, { variant: 'Veg', - diet: 'veg', desc: 'With exotic vegetables, creamy cheesy sauce + garlic bread', + ing: ['gluten', 'dairy'], }), item('main-course', 'Penne Alfredo', 649, { variant: 'Chicken', - diet: 'nonveg', desc: 'With exotic vegetables, creamy cheesy sauce + garlic bread', + ing: ['gluten', 'dairy', 'chicken'], }), item('main-course', 'Penne Salsa Rosa', 599, { variant: 'Veg', - diet: 'veg', desc: 'Penne in mix sauce, exotic vegetables + garlic bread', + ing: ['gluten', 'dairy'], }), item('main-course', 'Penne Salsa Rosa', 649, { variant: 'Chicken', - diet: 'nonveg', desc: 'Penne in mix sauce, exotic vegetables + garlic bread', + ing: ['gluten', 'dairy', 'chicken'], }), item('main-course', 'Pan Seared Salmon', 1259, { - diet: 'nonveg', desc: 'Creamy mashed potato, grilled vegetables + lemon caper sauce', + ing: ['fish', 'dairy'], }), item('main-course', 'Spaghetti Aglio e Olio', 549, { variant: 'Veg', - diet: 'veg', desc: 'Olive oil, garlic, olives, chili flakes + garlic bread', + ing: ['gluten'], }), item('main-course', 'Spaghetti Aglio e Olio', 599, { variant: 'Chicken', - diet: 'nonveg', desc: 'Olive oil, garlic, olives, chili flakes + garlic bread', + ing: ['gluten', 'chicken'], }), item('main-course', 'Chow Mein', 399, { variant: 'Veg', - diet: 'veg', desc: 'Wok tossed noodles with soy, vinegar & chili sauce', + ing: ['gluten', 'soy'], }), item('main-course', 'Chow Mein', 429, { variant: 'Egg', - diet: 'nonveg', desc: 'Wok tossed noodles with soy, vinegar & chili sauce', + ing: ['gluten', 'soy', 'egg'], }), item('main-course', 'Chow Mein', 479, { variant: 'Chicken', - diet: 'nonveg', desc: 'Wok tossed noodles with soy, vinegar & chili sauce', + ing: ['gluten', 'soy', 'chicken'], }), item('main-course', 'Fried Rice', 399, { variant: 'Veg', - diet: 'veg', desc: 'Wok tossed rice with soy, vinegar & chili sauce', + ing: ['soy'], }), item('main-course', 'Fried Rice', 429, { variant: 'Egg', - diet: 'nonveg', desc: 'Wok tossed rice with soy, vinegar & chili sauce', + ing: ['soy', 'egg'], }), item('main-course', 'Fried Rice', 479, { variant: 'Chicken', - diet: 'nonveg', desc: 'Wok tossed rice with soy, vinegar & chili sauce', + ing: ['soy', 'chicken'], }), item('main-course', 'Thai Curry', 579, { variant: 'Veg', - diet: 'veg', tag: 'Green / Red', desc: 'Veggies cooked in creamy coconut gravy, curry paste + steamed rice', }), item('main-course', 'Thai Curry', 629, { variant: 'Chicken', - diet: 'nonveg', tag: 'Green / Red', desc: 'Veggies cooked in creamy coconut gravy, curry paste + steamed rice', + ing: ['chicken'], }), item('main-course', 'Thai Curry', 659, { variant: 'Prawns', - diet: 'nonveg', tag: 'Green / Red', desc: 'Veggies cooked in creamy coconut gravy, curry paste + steamed rice', + ing: ['prawns'], }), item('main-course', 'Chili Garlic Noodles', 429, { - diet: 'veg', desc: 'Noodles tossed with authentic chili-garlic sauce, spring onion and soya', + ing: ['gluten', 'soy'], }), // ------------------------------------------------------------------ Curries item('curries', 'Dal Tadka / Palak Dal / Lasooni Dal', 399, { - diet: 'veg', desc: 'Mix of moong & masoor dal, as you wish', + ing: ['dairy'], }), item('curries', 'Dal Makhani', 479, { - diet: 'veg', desc: 'Overnight slow cooked black urad dal, tempered with kasoori methi, cream & butter', + ing: ['dairy'], }), item('curries', 'Kasoori Subz Milani', 519, { - diet: 'veg', desc: 'Melange of seasonal vegetables tossed in onion masala, dried fenugreek', + ing: ['dairy'], }), item('curries', 'Kadhai / Makhani Paneer', 599, { - diet: 'veg', tag: 'Kadhai / Makhani', desc: 'Cottage cheese, capsicum, onion & kadhai masala / creamy tomato gravy', + ing: ['dairy'], }), item('curries', 'Methi Palak Paneer', 569, { - diet: 'veg', desc: 'Cubes of cottage cheese, spinach & fenugreek', + ing: ['dairy'], }), item('curries', 'Paneer Lababdar', 619, { - diet: 'veg', desc: 'Cubes of cottage cheese, bell pepper, onions & capsicum', + ing: ['dairy'], }), item('curries', 'Mushroom Hara Pyaz', 579, { - diet: 'veg', desc: 'Mushroom & hara pyaz tossed in chopped masala', + ing: ['mushroom'], }), item('curries', 'Punjabi Chana Masala', 459, { - diet: 'veg', desc: 'White chickpeas with fragrant herbs & spices', }), item('curries', 'Butter Chicken', 779, { - diet: 'nonveg', desc: 'Chicken on bone + creamy tomato gravy', + ing: ['chicken', 'dairy'], }), item('curries', 'Egg Curry', 519, { - diet: 'nonveg', desc: 'Hard boiled eggs in Indian gravy', + ing: ['egg'], }), item('curries', 'Punjabi Fish Curry', 759, { variant: 'Basa', - diet: 'nonveg', desc: 'Choice of protein cooked in spicy tangy, onion tomato curry', + ing: ['fish'], }), item('curries', 'Punjabi Fish Curry', 859, { variant: 'Sole', - diet: 'nonveg', desc: 'Choice of protein cooked in spicy tangy, onion tomato curry', + ing: ['fish'], }), item('curries', 'Punjabi Fish Curry', 899, { variant: 'Prawns', - diet: 'nonveg', desc: 'Choice of protein cooked in spicy tangy, onion tomato curry', + ing: ['prawns'], }), item('curries', 'Home Style Chicken Curry', 659, { - diet: 'nonveg', desc: 'Chicken on bone + home style gravy', + ing: ['chicken'], }), item('curries', 'Kadhai Chicken', 779, { - diet: 'nonveg', desc: 'Chicken on bone + kadhai masala', + ing: ['chicken'], }), item('curries', 'Mutton Rogan Josh', 829, { - diet: 'nonveg', desc: 'Tender mutton on the bone, Kashmiri style', + ing: ['mutton'], }), item('curries', 'Kulcha with Curry', 299, { variant: 'Paneer', - diet: 'veg', desc: 'Fresh kulcha served with your choice of curry', + ing: ['gluten', 'dairy'], }), item('curries', 'Kulcha with Curry', 499, { variant: 'Chicken', - diet: 'nonveg', desc: 'Fresh kulcha served with your choice of curry', + ing: ['gluten', 'chicken'], }), item('curries', 'Kulcha with Curry', 599, { variant: 'Mutton', - diet: 'nonveg', desc: 'Fresh kulcha served with your choice of curry', + ing: ['gluten', 'mutton'], }), // --------------------------------------------------- Biryanis, Rice & Breads item('biryanis-rice-breads', 'Biryani', 779, { variant: 'Chicken', - diet: 'nonveg', desc: 'Aromatic basmati rice cooked in saffron & spices + mix raita', + ing: ['chicken', 'dairy'], }), item('biryanis-rice-breads', 'Biryani', 639, { variant: 'Egg', - diet: 'nonveg', desc: 'Aromatic basmati rice cooked in saffron & spices + mix raita', + ing: ['egg', 'dairy'], }), item('biryanis-rice-breads', 'Biryani', 889, { variant: 'Mutton', - diet: 'nonveg', desc: 'Aromatic basmati rice cooked in saffron & spices + mix raita', + ing: ['mutton', 'dairy'], }), item('biryanis-rice-breads', 'Biryani', 599, { variant: 'Vegetable', - diet: 'veg', desc: 'Aromatic basmati rice cooked in saffron & spices + mix raita', + ing: ['dairy'], }), - item('biryanis-rice-breads', 'Jeera Rice', 269, { diet: 'veg' }), - item('biryanis-rice-breads', 'Steamed Rice', 239, { diet: 'veg' }), - item('biryanis-rice-breads', 'Dhungari Burani Raita', 199, { diet: 'veg' }), - item('biryanis-rice-breads', 'Masala Boondi Raita', 189, { diet: 'veg' }), - item('biryanis-rice-breads', 'Onion, Tomato, Cucumber Raita', 189, { diet: 'veg' }), - item('biryanis-rice-breads', 'Pudina Raita', 189, { diet: 'veg' }), - item('biryanis-rice-breads', 'Butter Roti', 79, { diet: 'veg' }), - item('biryanis-rice-breads', 'Butter Naan', 99, { diet: 'veg' }), - item('biryanis-rice-breads', 'Garlic / Coriander Naan', 109, { diet: 'veg' }), - item('biryanis-rice-breads', 'Kachi Haldi Naan', 109, { diet: 'veg' }), - item('biryanis-rice-breads', 'Laccha / Pudina Parantha', 99, { diet: 'veg' }), - item('biryanis-rice-breads', 'Missi Roti', 99, { diet: 'veg' }), - item('biryanis-rice-breads', 'Onion Kulcha', 109, { diet: 'veg' }), - item('biryanis-rice-breads', 'Paneer Kulcha', 179, { diet: 'veg' }), - item('biryanis-rice-breads', 'Tandoori Naan', 89, { diet: 'veg' }), - item('biryanis-rice-breads', 'Tandoori Roti', 69, { diet: 'veg' }), - item('biryanis-rice-breads', 'Khamiri Roti', 79, { diet: 'veg' }), + item('biryanis-rice-breads', 'Jeera Rice', 269, {}), + item('biryanis-rice-breads', 'Steamed Rice', 239, {}), + item('biryanis-rice-breads', 'Dhungari Burani Raita', 199, { ing: ['dairy'] }), + item('biryanis-rice-breads', 'Masala Boondi Raita', 189, { ing: ['dairy'] }), + item('biryanis-rice-breads', 'Onion, Tomato, Cucumber Raita', 189, { ing: ['dairy'] }), + item('biryanis-rice-breads', 'Pudina Raita', 189, { ing: ['dairy'] }), + item('biryanis-rice-breads', 'Butter Roti', 79, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Butter Naan', 99, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Garlic / Coriander Naan', 109, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Kachi Haldi Naan', 109, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Laccha / Pudina Parantha', 99, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Missi Roti', 99, { ing: ['gluten'] }), + item('biryanis-rice-breads', 'Onion Kulcha', 109, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Paneer Kulcha', 179, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Tandoori Naan', 89, { ing: ['gluten', 'dairy'] }), + item('biryanis-rice-breads', 'Tandoori Roti', 69, { ing: ['gluten'] }), + item('biryanis-rice-breads', 'Khamiri Roti', 79, { ing: ['gluten'] }), // ----------------------------------------------------------------- Desserts item('desserts', 'Baked Cheesecake', 419, { - diet: 'veg', desc: 'Fresh berries compote', + ing: ['dairy', 'gluten', 'egg'], }), item('desserts', 'Churros', 299, { - diet: 'veg', desc: 'Chocolate sauce, caramel sauce & whipped cream', + ing: ['gluten', 'egg', 'dairy'], }), item('desserts', 'Dark Chocolate Brownie with Ice Cream', 339, { - diet: 'veg', desc: 'Brownie topped with vanilla ice cream + chocolate sauce', + ing: ['gluten', 'egg', 'dairy'], }), // ------------------------------------------------------------------- Pizzas item('pizzas', 'Burnt Garlic Mushroom Pizza', 589, { - diet: 'veg', desc: 'Sautéed mushrooms, burnt garlic, onions', + ing: ['gluten', 'dairy', 'mushroom'], }), item('pizzas', 'Garden Fresh Pizza', 579, { - diet: 'veg', desc: 'American corn, onion, tomato, capsicum & jalapeno', + ing: ['gluten', 'dairy'], }), item('pizzas', 'Exotic Pizza', 599, { - diet: 'veg', desc: 'Broccoli, zucchini, bell pepper, olives & baby corn', + ing: ['gluten', 'dairy'], }), item('pizzas', 'Margherita Pizza', 559, { - diet: 'veg', desc: 'Mozzarella cheese & fresh basil', + ing: ['gluten', 'dairy'], }), item('pizzas', 'Paneer Tikka Pizza', 599, { - diet: 'veg', desc: 'Paneer tikka, onion, tomato, capsicum', + ing: ['gluten', 'dairy'], }), item('pizzas', 'Sundried-Tomato & Olives Pizza', 589, { - diet: 'veg', desc: 'Homemade sundried tomato, caper & fresh basil', + ing: ['gluten', 'dairy'], }), item('pizzas', 'BBQ Pulled Chicken Pizza', 719, { - diet: 'nonveg', desc: 'Slow cooked pulled chicken, BBQ sauce & jalapeno', + ing: ['gluten', 'dairy', 'chicken'], }), item('pizzas', 'Chicken Overload Pizza', 729, { - diet: 'nonveg', desc: 'Chicken tikka, BBQ chicken, herb chicken, onion', + ing: ['gluten', 'dairy', 'chicken'], }), item('pizzas', 'Chicken Tikka Pizza', 719, { - diet: 'nonveg', desc: 'Chicken tikka, onion, tomato & capsicum', + ing: ['gluten', 'dairy', 'chicken'], }), item('pizzas', 'Meat Lover Pizza', 729, { - diet: 'nonveg', desc: 'Mutton seekh, tikka chicken & chicken salami', + ing: ['gluten', 'dairy', 'chicken', 'mutton'], }), item('pizzas', 'Pepperoni Pizza', 769, { - diet: 'nonveg', desc: 'Pork pepperoni, jalapeno, olives & fresh basil', + ing: ['gluten', 'dairy', 'pork'], }), item('pizzas', 'Peri-Peri Chicken Pizza', 729, { - diet: 'nonveg', desc: 'Peri peri marinated chicken, olives & pickled onion', + ing: ['gluten', 'dairy', 'chicken'], }), ] diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts index e10a8fe..df98d64 100644 --- a/app/src/lib/data.ts +++ b/app/src/lib/data.ts @@ -4,6 +4,7 @@ import { createReservationRecord, fetchBrews, fetchExperiences, + fetchIngredients, fetchMenuCategories, fetchMenuItems, fetchPairings, @@ -40,12 +41,13 @@ export const getBrewsData = createServerFn({ method: 'GET' }).handler(async () = }) export const getMenuData = createServerFn({ method: 'GET' }).handler(async () => { - const [categories, items, pairings] = await Promise.all([ + const [categories, items, pairings, ingredients] = await Promise.all([ fetchMenuCategories(), fetchMenuItems(), fetchPairings(), + fetchIngredients(), ]) - return { categories, items, pairings } + return { categories, items, pairings, ingredients } }) export const getVisitData = createServerFn({ method: 'GET' }).handler(() => fetchSettings()) diff --git a/app/src/lib/pb.ts b/app/src/lib/pb.ts index 23565de..0e86606 100644 --- a/app/src/lib/pb.ts +++ b/app/src/lib/pb.ts @@ -9,10 +9,11 @@ import seed from '~/data/content.json' // The food menu lives in its own module (shared with scripts/update-menu.mjs) // instead of content.json, since it is synced by its own script. -import { MENU_CATEGORIES, MENU_ITEMS } from '~/data/menu-data.mjs' +import { INGREDIENTS, MENU_CATEGORIES, MENU_ITEMS } from '~/data/menu-data.mjs' import type { Brew, Experience, + Ingredient, MenuCategory, MenuItem, Pairing, @@ -79,6 +80,10 @@ export async function fetchMenuItems(): Promise { return orFallback(await pbList('menu_items'), MENU_ITEMS as unknown as MenuItem[]) } +export async function fetchIngredients(): Promise { + return orFallback(await pbList('ingredients'), INGREDIENTS as unknown as Ingredient[]) +} + export async function fetchPairings(): Promise { return orFallback(await pbList('pairings'), seed.pairings) } diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 20fec9c..df61b1f 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -65,6 +65,18 @@ export interface MenuCategory { sort: number } +export interface Ingredient { + /** PocketBase record id (absent on code fallback data). */ + id?: string + uid: string + name: string + /** Emoji shown as the ingredient's icon on menu cards. */ + icon: string + /** 'veg' | 'nonveg' — an item is non-veg when any ingredient is. */ + diet_class: 'veg' | 'nonveg' + sort: number +} + export interface MenuItem { uid: string category: string @@ -75,8 +87,8 @@ export interface MenuItem { description?: string /** Rupees as a decimal number; paise after the point (449.5 = ₹449.50). */ price: number - /** Dietary status; 'mixed' items (e.g. Caesar Salad) get no badge. */ - diet?: 'veg' | 'nonveg' | 'mixed' + /** Ingredient record ids (from PocketBase) or uids (from the fallback). */ + ingredients?: string[] pairing?: string tag?: string image?: string diff --git a/app/src/routes/menu.tsx b/app/src/routes/menu.tsx index 15d185d..092b13d 100644 --- a/app/src/routes/menu.tsx +++ b/app/src/routes/menu.tsx @@ -1,6 +1,15 @@ import { createFileRoute } from '@tanstack/react-router' import { getMenuData } from '~/lib/data' import { MenuBrowser } from '~/components/menu-browser' +import type { Ingredient } from '~/lib/types' + +/** + * Menu item ingredient fields hold PocketBase record ids, while the offline + * fallback carries uids — index both so either path resolves. + */ +function indexIngredients(ingredients: Ingredient[]): Map { + return new Map(ingredients.flatMap((ing) => [[ing.id ?? ing.uid, ing], [ing.uid, ing]] as const)) +} export const Route = createFileRoute('/menu')({ head: () => ({ @@ -18,7 +27,8 @@ export const Route = createFileRoute('/menu')({ }) function MenuPage() { - const { categories, items, pairings } = Route.useLoaderData() + const { categories, items, pairings, ingredients } = Route.useLoaderData() + const ingredientIndex = indexIngredients(ingredients as Ingredient[]) return (
@@ -44,7 +54,7 @@ function MenuPage() { {/* Interactive Menu + Sticky Filter */} - + {/* Pairing Recommendations Banner */}
diff --git a/pb/migrations/1788500200_menu_ingredients.js b/pb/migrations/1788500200_menu_ingredients.js new file mode 100644 index 0000000..ca6f6b8 --- /dev/null +++ b/pb/migrations/1788500200_menu_ingredients.js @@ -0,0 +1,125 @@ +/// +migrate( + (app) => { + // v4: ingredients become a many-to-many relation instead of the diet + // select. Each ingredient carries its own icon (emoji) and diet class; + // an item's veg/non-veg badge is derived from its ingredients. The menu + // records are re-synced by scripts/update-menu.mjs. + const ingredientsCollectionDef = () => + new Collection({ + name: 'ingredients', + type: 'base', + listRule: '', + viewRule: '', + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: 'uid', type: 'text', required: true }, + { name: 'name', type: 'text', required: true }, + { name: 'icon', type: 'text' }, + { name: 'diet_class', type: 'select', maxSelect: 1, values: ['veg', 'nonveg'], required: true }, + { name: 'sort', type: 'number' }, + ], + indexes: ['CREATE UNIQUE INDEX idx_ingredients_uid ON ingredients (uid)'], + }) + + // app.save() returns nil (falsy) on success, so don't test its return + // value — resolve the saved collection by name instead. The lookup-first + // pattern keeps the migration idempotent if a previous run created the + // collection before failing. + let ingredientsCollection + try { + ingredientsCollection = app.findCollectionByNameOrId('ingredients') + } catch (e) { + app.save(ingredientsCollectionDef()) + ingredientsCollection = app.findCollectionByNameOrId('ingredients') + } + + try { + const oldCollection = app.findCollectionByNameOrId('menu_items') + app.delete(oldCollection) + } catch (e) { + // collection may not exist on a fresh database — nothing to migrate + } + + const menuItems = new Collection({ + name: 'menu_items', + type: 'base', + listRule: '', + viewRule: '', + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: 'uid', type: 'text', required: true }, + { name: 'category', type: 'text', required: true }, + { name: 'name', type: 'text', required: true }, + { name: 'variant', type: 'text' }, + { name: 'description', type: 'text' }, + { name: 'price', type: 'number', required: true }, + { name: 'ingredients', type: 'relation', collectionId: ingredientsCollection.id, cascadeDelete: false, maxSelect: 99 }, + { name: 'pairing', type: 'text' }, + { name: 'tag', type: 'text' }, + { name: 'image', type: 'text' }, + { name: 'sku', type: 'text' }, + { name: 'available', type: 'bool' }, + { name: 'tax_category', type: 'text' }, + { name: 'sort', type: 'number' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_menu_items_uid ON menu_items (uid)', + 'CREATE UNIQUE INDEX idx_menu_items_sku ON menu_items (sku) WHERE sku != \'\'', + ], + }) + return app.save(menuItems) + }, + (app) => { + // Revert to the v3 menu_items (diet select) and drop ingredients. + try { + const newCollection = app.findCollectionByNameOrId('menu_items') + app.delete(newCollection) + } catch (e) { + // nothing to revert + } + + const menuItems = new Collection({ + name: 'menu_items', + type: 'base', + listRule: '', + viewRule: '', + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: 'uid', type: 'text', required: true }, + { name: 'category', type: 'text', required: true }, + { name: 'name', type: 'text', required: true }, + { name: 'variant', type: 'text' }, + { name: 'description', type: 'text' }, + { name: 'price', type: 'number', required: true }, + { name: 'diet', type: 'select', maxSelect: 1, values: ['veg', 'nonveg', 'mixed'] }, + { name: 'pairing', type: 'text' }, + { name: 'tag', type: 'text' }, + { name: 'image', type: 'text' }, + { name: 'sku', type: 'text' }, + { name: 'available', type: 'bool' }, + { name: 'tax_category', type: 'text' }, + { name: 'sort', type: 'number' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_menu_items_uid ON menu_items (uid)', + 'CREATE UNIQUE INDEX idx_menu_items_sku ON menu_items (sku) WHERE sku != \'\'', + ], + }) + app.save(menuItems) + + try { + const ingredients = app.findCollectionByNameOrId('ingredients') + app.delete(ingredients) + } catch (e) { + // nothing to revert + } + return true + } +) diff --git a/scripts/update-menu.mjs b/scripts/update-menu.mjs index 0708e0f..0c44968 100755 --- a/scripts/update-menu.mjs +++ b/scripts/update-menu.mjs @@ -2,8 +2,9 @@ /** * Menu sync — replaces the PocketBase menu with app/src/data/menu-data.mjs. * - * Deletes every record in menu_categories / menu_items and inserts the - * current food menu from code. Nothing else is touched. + * Deletes every record in menu_categories / ingredients / menu_items and + * inserts the current food menu from code. Item ingredient uids are mapped + * to the freshly created ingredient record ids. Nothing else is touched. * * Works against any PocketBase instance (local dev or production): * @@ -19,7 +20,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) -const { MENU_CATEGORIES, MENU_ITEMS } = await import( +const { INGREDIENTS, MENU_CATEGORIES, MENU_ITEMS } = await import( new URL(`file://${join(here, '../app/src/data/menu-data.mjs')}`) ) @@ -84,26 +85,44 @@ async function createRecord(collection, token, body) { const text = await res.text() throw new Error(`create ${collection}/${body.uid} failed (HTTP ${res.status}): ${text}`) } + return res.json() } try { const token = await auth() console.log(`[menu] authenticated against ${PB_URL}`) - for (const [collection, entries] of [ - ['menu_categories', MENU_CATEGORIES], - ['menu_items', MENU_ITEMS], - ]) { + async function replaceCollection(collection, entries) { const existing = await listAll(collection, token) for (const record of existing) { await deleteRecord(collection, token, record.id) } + const idsByUid = new Map() for (const entry of entries) { - await createRecord(collection, token, entry) + const created = await createRecord(collection, token, entry) + idsByUid.set(entry.uid, created.id) } console.log(`[menu] ${collection}: -${existing.length} old, +${entries.length} new`) + return idsByUid } + // Ingredients first — menu items reference their PocketBase record ids. + await replaceCollection('menu_categories', MENU_CATEGORIES) + const ingredientIds = await replaceCollection('ingredients', INGREDIENTS) + + const items = MENU_ITEMS.map((entry) => { + const { ingredients, ...rest } = entry + return { + ...rest, + ingredients: (ingredients ?? []).map((uid) => { + const id = ingredientIds.get(uid) + if (!id) throw new Error(`menu item ${entry.uid} references unknown ingredient '${uid}'`) + return id + }), + } + }) + await replaceCollection('menu_items', items) + console.log('[menu] done — the live menu now matches app/src/data/menu-data.mjs') } catch (error) { console.error(`[menu] failed: ${error.message}`)