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:
@@ -165,10 +165,27 @@ node scripts/update-menu.mjs
|
|||||||
```
|
```
|
||||||
|
|
||||||
The script **replaces** all `menu_categories` / `menu_items` records (deletes old, inserts
|
The script **replaces** all `menu_categories` / `menu_items` records (deletes old, inserts
|
||||||
new) and touches nothing else. Item prices are text, so ranges like `449/549` work;
|
new) and touches nothing else.
|
||||||
`group` (`veg` / `nonveg`) drives the dietary badge; `image` is an optional URL. Admin edits
|
|
||||||
to menu records are overwritten by the next sync — treat this file as the menu's source of
|
### `menu_items` schema (POS-oriented)
|
||||||
truth.
|
|
||||||
|
- `uid` — stable slug identity
|
||||||
|
- `category` — slug → `menu_categories`
|
||||||
|
- `name` / `variant` — variants are **separate products** whenever their price differs
|
||||||
|
("Penne Arrabiata" + variant "Veg" ₹589 / "Chicken" ₹639); the POS can group by `name`
|
||||||
|
- `description`
|
||||||
|
- `price` — number, whole rupees (449 = ₹449)
|
||||||
|
- `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
|
||||||
|
- `tag` — badge chip; also carries same-price choice lists (e.g. "Malai / Angara")
|
||||||
|
- `image` — optional URL
|
||||||
|
- `sku` — unique POS code, `GB-<CAT>-<NNN>` (e.g. GB-MNC-014)
|
||||||
|
- `available` — false hides the item from the site (86'd) without deleting it
|
||||||
|
- `tax_category` — default `food-5-gst`; adjust per item for POS GST mapping
|
||||||
|
|
||||||
|
Admin edits to menu records are overwritten by the next sync — treat `menu-data.mjs` as the
|
||||||
|
menu's source of truth.
|
||||||
|
|
||||||
## Container environment
|
## Container environment
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function MenuBrowser({
|
|||||||
}) {
|
}) {
|
||||||
const [active, setActive] = React.useState<string>('all')
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,30 +73,27 @@ function MenuCard({ item }: { item: MenuItem }) {
|
|||||||
className="relative h-64 w-full bg-cover bg-center"
|
className="relative h-64 w-full bg-cover bg-center"
|
||||||
style={{ backgroundImage: `url('${item.image}')` }}
|
style={{ backgroundImage: `url('${item.image}')` }}
|
||||||
>
|
>
|
||||||
{item.group ? (
|
<ItemBadges item={item} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex h-32 w-full items-center justify-center bg-surface-container-high">
|
<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>
|
<span className="material-symbols-outlined text-[44px] text-primary/60">{icon}</span>
|
||||||
{item.group ? (
|
<div className="absolute right-space-sm top-space-sm">
|
||||||
<div className="absolute right-space-sm top-space-sm">
|
<ItemBadges item={item} />
|
||||||
<DietBadge group={item.group} />
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-grow flex-col justify-between">
|
<div className="flex flex-grow flex-col justify-between">
|
||||||
<div className="p-space-lg">
|
<div className="p-space-lg">
|
||||||
<div className="mb-space-xs flex items-start justify-between gap-space-md">
|
<div className="mb-space-xs flex items-start justify-between gap-space-md">
|
||||||
<h3 className="font-headline-md text-on-surface">{item.name}</h3>
|
<h3 className="font-headline-md text-on-surface">
|
||||||
{item.price ? (
|
{item.name}
|
||||||
<span className="shrink-0 font-headline-md text-primary">₹{item.price}</span>
|
{item.variant ? (
|
||||||
) : null}
|
<span className="text-on-surface-variant"> – {item.variant}</span>
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
<span className="shrink-0 font-headline-md text-primary">₹{item.price}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.description ? (
|
{item.description ? (
|
||||||
<p className="text-body-md text-on-surface-variant">{item.description}</p>
|
<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. */
|
/** FSSAI-style dietary mark: green square for veg, red for non-veg. */
|
||||||
function DietBadge({ group }: { group: string }) {
|
function DietBadge({ isVeg }: { isVeg: boolean }) {
|
||||||
const isVeg = group === 'veg'
|
|
||||||
const border = isVeg ? 'border-green-500' : 'border-red-500'
|
const border = isVeg ? 'border-green-500' : 'border-red-500'
|
||||||
const dot = isVeg ? 'bg-green-500' : 'bg-red-500'
|
const dot = isVeg ? 'bg-green-500' : 'bg-red-500'
|
||||||
return (
|
return (
|
||||||
|
|||||||
+700
-384
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -71,12 +71,12 @@ export async function fetchTestimonials(): Promise<Testimonial[]> {
|
|||||||
export async function fetchMenuCategories(): Promise<MenuCategory[]> {
|
export async function fetchMenuCategories(): Promise<MenuCategory[]> {
|
||||||
return orFallback(
|
return orFallback(
|
||||||
await pbList<MenuCategory>('menu_categories'),
|
await pbList<MenuCategory>('menu_categories'),
|
||||||
MENU_CATEGORIES as MenuCategory[],
|
MENU_CATEGORIES as unknown as MenuCategory[],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMenuItems(): Promise<MenuItem[]> {
|
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[]> {
|
export async function fetchPairings(): Promise<Pairing[]> {
|
||||||
|
|||||||
+16
-8
@@ -68,15 +68,23 @@ export interface MenuCategory {
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
uid: string
|
uid: string
|
||||||
category: string
|
category: string
|
||||||
|
/** Base dish name; the variant is a separate product. */
|
||||||
name: string
|
name: string
|
||||||
description: string
|
/** "Veg", "Chicken", "Basa", "Half"… — same dish, different product. */
|
||||||
/** Text so price ranges like "449/549" (Veg/Chicken) work. */
|
variant?: string
|
||||||
price: string
|
description?: string
|
||||||
pairing: string
|
/** Whole rupees (449 = ₹449). */
|
||||||
tag: string
|
price: number
|
||||||
image: string
|
/** Dietary status; 'mixed' items (e.g. Caesar Salad) get no badge. */
|
||||||
/** 'veg' | 'nonveg' | '' — drives the dietary badge on the menu card. */
|
diet?: 'veg' | 'nonveg' | 'mixed'
|
||||||
group: string
|
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
|
sort: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate(
|
||||||
|
(app) => {
|
||||||
|
// v3 menu_items: POS-friendly schema. Variants are separate products with
|
||||||
|
// a numeric rupee price; is_veg replaces the text group; adds sku,
|
||||||
|
// available and tax_category. The old collection only held the previous
|
||||||
|
// menu sync, so it is replaced wholesale (records are re-synced by
|
||||||
|
// scripts/update-menu.mjs).
|
||||||
|
try {
|
||||||
|
const oldCollection = app.findCollectionByNameOrId('menu_items')
|
||||||
|
app.delete(oldCollection)
|
||||||
|
} catch (e) {
|
||||||
|
// collection may not exist on a fresh database — nothing to migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = 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 != \'\'',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return app.save(collection)
|
||||||
|
},
|
||||||
|
(app) => {
|
||||||
|
// Revert to the v2 schema.
|
||||||
|
try {
|
||||||
|
const newCollection = app.findCollectionByNameOrId('menu_items')
|
||||||
|
app.delete(newCollection)
|
||||||
|
} catch (e) {
|
||||||
|
// nothing to revert
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = 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: 'description', type: 'text' },
|
||||||
|
{ name: 'price', type: 'text' },
|
||||||
|
{ name: 'pairing', type: 'text' },
|
||||||
|
{ name: 'tag', type: 'text' },
|
||||||
|
{ name: 'image', type: 'text' },
|
||||||
|
{ name: 'group', type: 'text' },
|
||||||
|
{ name: 'sort', type: 'number' },
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX idx_menu_items_uid ON menu_items (uid)'],
|
||||||
|
})
|
||||||
|
return app.save(collection)
|
||||||
|
}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user