Contextual menus adapt the product catalog based on the customer’s table type and special events. A bar table shows only drinks, a dining table shows all categories, and a birthday table adds an automatic discount with animated banner.
How it works
The menu filtering logic is centralized in the useMenuLogic hook (src/lib/modules/menu/useMenuLogic.ts):
export function useMenuLogic () {
const { tableId } = useLocalSearchParams <{ tableId ?: string }>();
const currentTable = useTableStore (( s ) => s . currentTable );
const setBirthdayMode = useCartStore (( s ) => s . setBirthdayMode );
// Filter products based on the table's menuType
const products : Product [] =
currentTable ?. menuType === 'DRINKS_ONLY'
? PRODUCTS . filter (( p ) => p . category === 'DRINK' )
: PRODUCTS ;
// Group by category for section rendering
const sections = [
{ key: 'FOOD' , title: 'Platos' , data: products . filter (( p ) => p . category === 'FOOD' ) },
{ key: 'SNACK' , title: 'Snacks' , data: products . filter (( p ) => p . category === 'SNACK' ) },
{ key: 'DRINK' , title: 'Bebidas' , data: products . filter (( p ) => p . category === 'DRINK' ) },
{ key: 'DESSERT' , title: 'Postres' , data: products . filter (( p ) => p . category === 'DESSERT' ) },
]. filter (( s ) => s . data . length > 0 );
return { table: currentTable , sections , isBirthdayMode , discount };
}
Empty sections are automatically filtered out with .filter((s) => s.data.length > 0). This ensures bar tables don’t show empty “Food” or “Desserts” sections.
Tables with menuType: 'FULL' display all product categories:
Product fetch
All products are loaded from PRODUCTS array (src/lib/core/mockData.ts)
Category grouping
Products are grouped into four sections:
Food: Main dishes (category: FOOD)
Snacks: Appetizers and sides (category: SNACK)
Drinks: Beverages (category: DRINK)
Desserts: Sweets (category: DESSERT)
Rendering
The menu component renders a SectionList with category headers
Example table:
{
id : 'TABLE_HALL_05' ,
displayName : 'Salon 05' ,
menuType : 'FULL' ,
status : 'FREE' ,
specialEvent : 'NONE'
}
Drinks only
Bar tables with menuType: 'DRINKS_ONLY' show only beverages:
src/lib/modules/menu/useMenuLogic.ts
const products : Product [] =
currentTable ?. menuType === 'DRINKS_ONLY'
? PRODUCTS . filter (( p ) => p . category === 'DRINK' )
: PRODUCTS ;
This filtering happens before category grouping, so the sections array only contains the “Bebidas” section.
Example table:
{
id : 'TABLE_BAR_01' ,
displayName : 'Barra 01' ,
menuType : 'DRINKS_ONLY' ,
status : 'FREE' ,
specialEvent : 'NONE'
}
Customers at drinks-only tables cannot add food to their cart. The products simply don’t appear in the menu.
Special events
Birthday mode
Tables with specialEvent: 'BIRTHDAY' trigger special behavior:
Discount activation
Visual feedback
Cart calculation
The discount is activated as soon as the table is loaded (src/lib/modules/menu/useMenuLogic.ts:22): useEffect (() => {
if ( ! currentTable ) return ;
if ( currentTable . specialEvent === 'BIRTHDAY' ) {
setBirthdayMode ( true , currentTable . discount ?? 0 );
} else {
setBirthdayMode ( false , 0 );
}
}, [ currentTable ?. id ]);
Default discount: 15% (0.15) A BirthdayBanner component renders at the top of the menu with:
Animated cake emoji
“Feliz Cumpleaños” message
Discount percentage display
The discount is applied to the cart subtotal (src/stores/useCartStore.ts:19): function calcTotal ( items : CartItem [], discount : number ) : number {
const subtotal = items . reduce (
( sum , item ) => sum + item . product . price * item . quantity ,
0
);
return parseFloat (( subtotal * ( 1 - discount )). toFixed ( 2 ));
}
Example:
Subtotal: $100.00
Discount: 15%
Total: $85.00
Example table:
{
id : 'TABLE_BDAY_99' ,
displayName : 'Mesa Especial' ,
menuType : 'FULL' ,
status : 'FREE' ,
specialEvent : 'BIRTHDAY' ,
discount : 0.15 ,
animation : 'cake'
}
Product structure
Products are defined with the Product type (src/lib/core/types.ts:23):
export interface Product {
id : string ;
name : string ;
price : number ;
category : ProductCategory ;
image : string ;
description ?: string ;
}
export type ProductCategory = 'FOOD' | 'DRINK' | 'SNACK' | 'DESSERT' ;
Example product:
{
id : 'PROD_001' ,
name : 'Burger Clásica' ,
price : 12.50 ,
category : 'FOOD' ,
image : require ( '@/assets/products/burger.jpg' ),
description : 'Carne Angus, queso cheddar, lechuga, tomate'
}
State management
The menu system uses three stores:
Table store
src/stores/useTableStore.ts
const currentTable = useTableStore (( s ) => s . currentTable );
Stores the active TableData object with:
id: QR code string
displayName: Human-readable table name
menuType: 'FULL' | 'DRINKS_ONLY'
specialEvent: 'NONE' | 'BIRTHDAY'
discount: Optional discount percentage (0.15 = 15%)
Cart store
src/stores/useCartStore.ts
interface CartState {
items : CartItem [];
isBirthdayMode : boolean ;
discount : number ;
total : number ;
addItem : ( product : Product ) => void ;
removeItem : ( productId : string ) => void ;
}
Adding items to cart
src/stores/useCartStore.ts
addItem : ( product : Product ) => {
const { items , discount } = get ();
const existing = items . find (( i ) => i . product . id === product . id );
const updated = existing
? items . map (( i ) =>
i . product . id === product . id
? { ... i , quantity: i . quantity + 1 }
: i
)
: [ ... items , { product , quantity: 1 }];
set ({ items: updated , total: calcTotal ( updated , discount ) });
}
The cart automatically recalculates the total whenever items are added or removed, respecting the current discount if birthday mode is active.
The menu screen uses a SectionList to render categorized products:
< SectionList
sections = { sections }
keyExtractor = {(item) => item. id }
renderItem = {({ item }) => <ProductCard product = { item } /> }
renderSectionHeader = {({ section : { title } }) => (
<Text style = {styles. sectionHeader } > { title } </ Text >
)}
ListHeaderComponent = {
isBirthdayMode ? <BirthdayBanner discount = { discount } /> : null
}
/>
In delivery mode, the menu always shows the full catalog regardless of table type, since there is no table (src/components/location/ContextSwitcher.tsx:92):
if ( distanceMeters > Config . restaurant . geofenceRadiusMeters ) {
setAppMode ( 'DELIVERY' );
setServiceType ( 'DELIVERY' );
// No table is set, so menuType filtering doesn't apply
}
The delivery catalog component loads all products directly:
const allProducts = PRODUCTS ; // No filtering
Edge cases
Deep links
If the user opens a deep link directly to the menu without scanning a QR code, the hook hydrates the table from route params (src/lib/modules/menu/useMenuLogic.ts:15):
const { tableId } = useLocalSearchParams <{ tableId ?: string }>();
useEffect (() => {
if ( tableId && ( ! currentTable || currentTable . id !== tableId )) {
setTable ( tableId );
}
}, [ tableId ]);
Missing table
If currentTable is null, the menu defaults to showing all products (full menu):
const products : Product [] =
currentTable ?. menuType === 'DRINKS_ONLY'
? PRODUCTS . filter (( p ) => p . category === 'DRINK' )
: PRODUCTS ;
The ?. optional chaining ensures the code doesn’t crash.
Birthday mode cleanup
Birthday mode is cleared when the session ends (src/stores/useCartStore.ts:66):
resetCart : () => {
set ({
items: [],
total: 0 ,
isBirthdayMode: false ,
discount: 0 ,
});
}
Scan a bar table QR code
Use QR code TABLE_BAR_01 to test drinks-only menu
Verify only drinks appear
The menu should show only the “Bebidas” section
Scan a dining table QR code
Use QR code TABLE_HALL_05 to test full menu
Verify all categories appear
The menu should show Food, Snacks, Drinks, and Desserts
Scan a birthday table QR code
Use QR code TABLE_BDAY_99 to test birthday mode
Verify discount and banner
The menu should show:
Animated birthday banner at the top
15% discount label
Reduced total in cart
Table Mode QR scanning and table session initialization
QR Scanner Camera-based QR code scanning
Payments Checkout with discount calculation
Delivery Mode Delivery orders (always full menu)