Menu schema v3: variants as separate products, POS-ready fields

- variants split into their own menu_items whenever price differs
  (name + variant fields; 164 products, 24 variant groups)
- price back to number (whole rupees); no more '449/549' strings
- diet select (veg/nonveg/mixed) instead of nullable bool — PocketBase
  serializes empty bools as false, which misfiled mixed dishes
- new fields for POS: sku (unique GB-CAT-NNN), available (86 toggle),
  tax_category (default food-5-gst)
- migration 1788500100_menu_items_v3 replaces menu_items; resync via
  scripts/update-menu.mjs
- cards render 'Dish – Variant', badges from diet, hide unavailable
This commit is contained in:
2026-09-03 18:39:00 +05:30
parent bcb41353ce
commit bea6710d38
6 changed files with 839 additions and 415 deletions
+20 -17
View File
@@ -24,7 +24,9 @@ export function MenuBrowser({
}) {
const [active, setActive] = React.useState<string>('all')
const visible = items.filter((item) => active === 'all' || item.category === active)
const visible = items.filter(
(item) => item.available !== false && (active === 'all' || item.category === active),
)
return (
<>
@@ -71,30 +73,27 @@ function MenuCard({ item }: { item: MenuItem }) {
className="relative h-64 w-full bg-cover bg-center"
style={{ backgroundImage: `url('${item.image}')` }}
>
{item.group ? (
<div className="absolute right-space-sm top-space-sm rounded bg-surface/85 px-space-sm py-space-xs backdrop-blur-sm">
<DietBadge group={item.group} />
</div>
) : null}
<ItemBadges item={item} />
</div>
) : (
<div className="relative flex h-32 w-full items-center justify-center bg-surface-container-high">
<span className="material-symbols-outlined text-[44px] text-primary/60">{icon}</span>
{item.group ? (
<div className="absolute right-space-sm top-space-sm">
<DietBadge group={item.group} />
</div>
) : null}
<div className="absolute right-space-sm top-space-sm">
<ItemBadges item={item} />
</div>
</div>
)}
<div className="flex flex-grow flex-col justify-between">
<div className="p-space-lg">
<div className="mb-space-xs flex items-start justify-between gap-space-md">
<h3 className="font-headline-md text-on-surface">{item.name}</h3>
{item.price ? (
<span className="shrink-0 font-headline-md text-primary">{item.price}</span>
) : null}
<h3 className="font-headline-md text-on-surface">
{item.name}
{item.variant ? (
<span className="text-on-surface-variant"> {item.variant}</span>
) : null}
</h3>
<span className="shrink-0 font-headline-md text-primary">{item.price}</span>
</div>
{item.description ? (
<p className="text-body-md text-on-surface-variant">{item.description}</p>
@@ -130,9 +129,13 @@ function MenuCard({ item }: { item: MenuItem }) {
)
}
function ItemBadges({ item }: { item: MenuItem }) {
if (item.diet !== 'veg' && item.diet !== 'nonveg') return null
return <DietBadge isVeg={item.diet === 'veg'} />
}
/** FSSAI-style dietary mark: green square for veg, red for non-veg. */
function DietBadge({ group }: { group: string }) {
const isVeg = group === 'veg'
function DietBadge({ isVeg }: { isVeg: boolean }) {
const border = isVeg ? 'border-green-500' : 'border-red-500'
const dot = isVeg ? 'bg-green-500' : 'bg-red-500'
return (
+700 -384
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -71,12 +71,12 @@ export async function fetchTestimonials(): Promise<Testimonial[]> {
export async function fetchMenuCategories(): Promise<MenuCategory[]> {
return orFallback(
await pbList<MenuCategory>('menu_categories'),
MENU_CATEGORIES as MenuCategory[],
MENU_CATEGORIES as unknown as MenuCategory[],
)
}
export async function fetchMenuItems(): Promise<MenuItem[]> {
return orFallback(await pbList<MenuItem>('menu_items'), MENU_ITEMS as MenuItem[])
return orFallback(await pbList<MenuItem>('menu_items'), MENU_ITEMS as unknown as MenuItem[])
}
export async function fetchPairings(): Promise<Pairing[]> {
+16 -8
View File
@@ -68,15 +68,23 @@ export interface MenuCategory {
export interface MenuItem {
uid: string
category: string
/** Base dish name; the variant is a separate product. */
name: string
description: string
/** Text so price ranges like "449/549" (Veg/Chicken) work. */
price: string
pairing: string
tag: string
image: string
/** 'veg' | 'nonveg' | '' — drives the dietary badge on the menu card. */
group: string
/** "Veg", "Chicken", "Basa", "Half"… — same dish, different product. */
variant?: string
description?: string
/** Whole rupees (449 = ₹449). */
price: number
/** Dietary status; 'mixed' items (e.g. Caesar Salad) get no badge. */
diet?: 'veg' | 'nonveg' | 'mixed'
pairing?: string
tag?: string
image?: string
/** Stable POS code, e.g. GB-MNC-014. */
sku?: string
/** false hides the item from the menu (86'd) without deleting it. */
available?: boolean
tax_category?: string
sort: number
}