Wire staff KDS, customer cart UX, and staff inventory voice AI.

Staff portal and customer flow now use live APIs for KDS tickets, cart/order status, and low-stock voice assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vikalp Paliwal 2026-08-13 11:11:06 +05:30
parent a03a620acc
commit fc4c42e20a
51 changed files with 14517 additions and 1749 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
graphify-out
.env

View File

@ -1,7 +1,9 @@
# RestroAI Frontend Environment Variables
# The HTTP URL of the remote backend server
VITE_API_URL=https://api.yourdomain.com
# Local full stack (docker compose up from retro_backend repo)
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
# The WebSocket URL of the remote backend server
VITE_WS_URL=wss://api.yourdomain.com
# Production
# VITE_API_URL=https://restro-backend.navigolabs.com
# VITE_WS_URL=wss://restro-backend.navigolabs.com

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

109
dev-dist/sw.js Normal file
View File

@ -0,0 +1,109 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5ccb27be'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "/index.html",
"revision": "0.fhp5592siig"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/staff\.html/, /^\/api/]
}));
workbox.registerRoute(({
url
}) => url.pathname.includes("/menu/public"), new workbox.StaleWhileRevalidate({
"cacheName": "restroai-menu",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 20,
maxAgeSeconds: 86400
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(({
request
}) => request.destination === "image", new workbox.CacheFirst({
"cacheName": "restroai-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 604800
})]
}), 'GET');
}));

4695
dev-dist/workbox-5ccb27be.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,16 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RestroAI Operations Platform - Kitchen & Analytics Suite</title>
<!-- Google Fonts: Inter, Plus Jakarta Sans & JetBrains Mono -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ac2d00" />
<meta name="description" content="RestroAI guest ordering — scan, order, pay at your table" />
<link rel="apple-touch-icon" href="/icons.svg" />
<title>RestroAI — Table Ordering</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/apps/customer/main.tsx"></script>
</body>
</html>

4813
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:customer": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
@ -16,7 +17,10 @@
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.2",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
},
"devDependencies": {
"@types/node": "^24.13.2",

18
packages/ui/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@restroai/ui",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^9.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@ -0,0 +1,117 @@
import React from 'react';
import { Chip, Box, Typography } from '@mui/material';
export type DietaryType = 'veg' | 'non-veg' | 'jain' | 'vegan';
export type OrderStatusLabel = 'pending' | 'preparing' | 'ready' | 'served';
export type OrderTypeLabel = 'Dine-In' | 'Takeaway' | 'Delivery';
interface StatusBadgeProps {
status?: OrderStatusLabel;
type?: OrderTypeLabel;
dietary?: DietaryType;
size?: 'small' | 'medium';
}
/** Veg / non-veg / status / order-type badges shared by PWA + dashboard. */
export const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
type,
dietary,
size = 'small',
}) => {
if (dietary) {
const config = {
veg: { label: 'VEG', color: '#2e7d32', bg: '#e8f5e9', border: '#2e7d32' },
'non-veg': { label: 'NON-VEG', color: '#c62828', bg: '#ffebee', border: '#c62828' },
jain: { label: 'JAIN', color: '#ef6c00', bg: '#fff3e0', border: '#ef6c00' },
vegan: { label: 'VEGAN', color: '#1565c0', bg: '#e3f2fd', border: '#1565c0' },
}[dietary];
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
border: `1px solid ${config.border}`,
borderRadius: '4px',
px: 0.6,
py: 0.2,
backgroundColor: config.bg,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: config.color,
}}
/>
<Typography
variant="caption"
sx={{
color: config.color,
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.05em',
}}
>
{config.label}
</Typography>
</Box>
);
}
if (status) {
const statusMap = {
pending: { label: 'PENDING', color: 'error' as const, bg: '#ffdad6' },
preparing: { label: 'PREPARING', color: 'warning' as const, bg: '#ffddba' },
ready: { label: 'READY', color: 'success' as const, bg: '#a3f69c' },
served: { label: 'SERVED', color: 'default' as const, bg: '#eeeeee' },
}[status];
return (
<Chip
label={statusMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
}}
color={statusMap.color}
/>
);
}
if (type) {
const typeMap = {
'Dine-In': { label: 'DINE-IN', color: '#ac2d00', bg: '#ffdbd1' },
Takeaway: { label: 'TAKEAWAY', color: '#546067', bg: '#d7e4ec' },
Delivery: { label: 'DELIVERY', color: '#845000', bg: '#ffddba' },
}[type];
return (
<Chip
label={typeMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
backgroundColor: typeMap.bg,
color: typeMap.color,
}}
/>
);
}
return null;
};
/** Alias for dietary-only usage. */
export const DietaryBadge: React.FC<{ dietary: DietaryType; size?: 'small' | 'medium' }> = ({
dietary,
size,
}) => <StatusBadge dietary={dietary} size={size} />;

3
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { theme } from './theme';
export { StatusBadge, DietaryBadge } from './StatusBadge';
export type { DietaryType, OrderStatusLabel, OrderTypeLabel } from './StatusBadge';

146
packages/ui/src/theme.ts Normal file
View File

@ -0,0 +1,146 @@
import { createTheme } from '@mui/material/styles';
/** Shared RestroAI MUI theme (customer PWA + staff dashboard). */
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#ac2d00',
light: '#ffb5a0',
dark: '#872100',
contrastText: '#ffffff',
},
secondary: {
main: '#546067',
light: '#818e95',
dark: '#2a363d',
contrastText: '#ffffff',
},
background: {
default: '#f8f9fa',
paper: '#ffffff',
},
error: {
main: '#ba1a1a',
light: '#ffdad6',
dark: '#93000a',
},
warning: {
main: '#845000',
light: '#ffddba',
dark: '#2b1700',
},
success: {
main: '#11651d',
light: '#a3f69c',
dark: '#003915',
},
info: {
main: '#00a6e0',
light: '#c4e7ff',
dark: '#00374d',
},
text: {
primary: '#1a1c1c',
secondary: '#5b4139',
},
divider: '#e4beb4',
},
typography: {
fontFamily: '"Inter", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 800,
letterSpacing: '-0.02em',
},
h2: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
letterSpacing: '-0.01em',
},
h3: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
},
h4: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 600,
},
h5: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
h6: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
subtitle1: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
body1: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.5,
},
body2: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.43,
},
button: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
textTransform: 'none',
},
caption: {
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
padding: '8px 16px',
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 8px rgba(172, 45, 0, 0.25)',
},
},
contained: {
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0px 2px 12px rgba(0, 0, 0, 0.05)',
border: '1px solid rgba(228, 190, 180, 0.4)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 600,
borderRadius: 6,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#ffffff',
color: '#1a1c1c',
boxShadow: '0px 1px 10px rgba(0,0,0,0.05)',
borderBottom: '1px solid #e2e2e2',
},
},
},
},
});

View File

@ -1,333 +1,2 @@
import React, { useState, useEffect } from 'react';
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
import { theme } from './theme/theme';
import { TableLandingView } from './components/customer/TableLandingView';
import { MenuBrowseView } from './components/customer/MenuBrowseView';
import { CustomizationModal } from './components/customer/CustomizationModal';
import { CartReviewView } from './components/customer/CartReviewView';
import { OrderStatusView } from './components/customer/OrderStatusView';
import { BillPaymentView } from './components/customer/BillPaymentView';
import { VoiceAssistantModal } from './components/customer/VoiceAssistantModal';
import { StaffLoginModal } from './components/admin/StaffLoginModal';
import { AdminDesktopShell } from './components/admin/AdminDesktopShell';
import type { MenuItem } from './data/menuData';
import type { KDSOrder, OrderStatus } from './types';
import { api, getBackendMenuId, getFrontendMenuItem, RestroWebSocket, subscribeToWsEvents, isDemoMode, setDemoModeChangeCallback } from './services/api';
type CustomerView = 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice';
const getTableNumFromUrl = (): number => {
const params = new URLSearchParams(window.location.search);
const tableParam = params.get('table');
if (tableParam) {
const parsed = parseInt(tableParam);
if (!isNaN(parsed)) return parsed;
}
const path = window.location.pathname;
const match = path.match(/\/scan\/(\d+)/);
if (match) {
return parseInt(match[1]);
}
return 12;
};
export const App: React.FC = () => {
const [appMode, setAppMode] = useState<'customer' | 'staff'>('customer');
const [customerView, setCustomerView] = useState<CustomerView>('landing');
const [staffRole, setStaffRole] = useState<'kitchen' | 'manager'>('kitchen');
const [staffLoginOpen, setStaffLoginOpen] = useState<boolean>(false);
const [voiceModalOpen, setVoiceModalOpen] = useState<boolean>(false);
const [demoActive, setDemoActive] = useState<boolean>(isDemoMode);
const [cart, setCart] = useState<{ [itemId: string]: number }>({});
const [customizingItem, setCustomizingItem] = useState<MenuItem | null>(null);
const [orders, setOrders] = useState<KDSOrder[]>([]);
// Monitor demo mode status
useEffect(() => {
setDemoActive(isDemoMode);
setDemoModeChangeCallback((demo) => {
setDemoActive(demo);
});
}, []);
// Fetch KDS board when staff logged in
const loadKDS = async () => {
try {
// Backend returns KdsBoardResponse: { restaurant_id, orders: KdsBoardOrder[] }
const data = await api.getKDSBoard(1); // 1 = restaurant ID
const ordersArray = Array.isArray(data) ? data : (data?.orders || []);
// Map API Order model to KDSOrder type if format differs
const mappedOrders: KDSOrder[] = ordersArray.map((ord: any) => {
// Map KDS status to Order status
let overallStatus: OrderStatus = 'pending';
if (ord.status === 'served') overallStatus = 'served';
else if (ord.status === 'ready') overallStatus = 'ready';
else if (ord.status === 'in_preparation' || ord.status === 'preparing') overallStatus = 'preparing';
return {
id: String(ord.id || ord.order_id),
ticketNumber: ord.ticketNumber || String(ord.id || ord.order_id).substring(0, 4),
tableNumber: ord.tableNumber || (ord.table_number ? String(ord.table_number) : 'Takeaway'),
orderType: ord.orderType || (ord.channel === 'touch' ? 'Dine-In' : 'Dine-In'),
status: overallStatus,
createdAt: ord.createdAt || ord.placed_at || new Date().toISOString(),
timeElapsedMinutes: ord.timeElapsedMinutes || 0,
priority: ord.priority || 'normal',
serverName: ord.serverName || 'System',
totalAmount: ord.totalAmount || Number(ord.subtotal || 0),
items: ord.items?.map((it: any) => {
const original = getFrontendMenuItem(it.menu_item_id);
return {
id: String(it.id),
name: it.name || original?.name || `Dish ${it.menu_item_id}`,
quantity: it.quantity,
price: it.price || (it.unit_price ? Number(it.unit_price) : (original?.price || 150)),
completed: it.kds_status === 'ready' || it.kds_status === 'served',
dietary: 'veg'
};
}) || []
};
});
setOrders(mappedOrders);
} catch (err) {
console.error('Failed to reload KDS board', err);
}
};
useEffect(() => {
if (appMode === 'staff') {
loadKDS();
// Setup WebSocket connection
const ws = new RestroWebSocket('kds', '1');
ws.connect();
// Subscribe to updates
const unsubscribe = subscribeToWsEvents((event) => {
if (event.type === 'kds.item_updated') {
loadKDS();
}
});
return () => {
ws.close();
unsubscribe();
};
}
}, [appMode]);
const handleUpdateCart = (itemId: string, quantity: number) => {
setCart((prev) => {
const copy = { ...prev };
if (quantity <= 0) {
delete copy[itemId];
} else {
copy[itemId] = quantity;
}
return copy;
});
};
const handleCustomizationConfirm = (item: MenuItem, quantity: number, _notes: string) => {
handleUpdateCart(item.id, (cart[item.id] || 0) + quantity);
};
const handleStartOrdering = async (language: string) => {
try {
const tableId = getTableNumFromUrl();
await api.startSession(tableId, language === 'english' ? 'en' : 'hi');
setCustomerView('menu');
} catch (err) {
console.warn('Could not initialize session, entering offline menu browse', err);
setCustomerView('menu');
}
};
const handlePlaceOrder = async (_orderNotes: string) => {
try {
const items = Object.entries(cart).map(([id, qty]) => ({
menu_item_id: getBackendMenuId(id),
quantity: qty,
customization_notes: []
}));
await api.placeOrder(items);
setCart({});
setCustomerView('status');
} catch (err) {
alert('Failed to place order: ' + err);
}
};
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
try {
const statusMap: Record<OrderStatus, string> = {
pending: 'queued',
preparing: 'in_prep',
ready: 'ready',
served: 'served'
};
const kdsStatus = statusMap[newStatus];
const order = orders.find((o) => o.id === orderId);
if (order) {
for (const item of order.items) {
await api.updateOrderItemStatus(item.id, kdsStatus);
}
}
loadKDS();
} catch (err) {
console.error('Failed to change status', err);
}
};
const handleToggleItem = async (orderId: string, itemId: string) => {
try {
const order = orders.find((o) => o.id === orderId);
const item = order?.items.find((i) => i.id === itemId);
if (item) {
const nextStatus = item.completed ? 'in_prep' : 'ready';
await api.updateOrderItemStatus(itemId, nextStatus);
loadKDS();
}
} catch (err) {
console.error('Failed to toggle item', err);
}
};
const handleAddSampleOrder = async () => {
// Add a sample order via API or locally
try {
const items = [
{ menu_item_id: 9, quantity: 2 }, // Butter chicken
{ menu_item_id: 11, quantity: 4 } // Garlic naan
];
await api.placeOrder(items);
loadKDS();
} catch (e) {
console.error('Failed to add sample order', e);
}
};
const handleAddOrderFromVoice = async (_transcript: string) => {
try {
const items = [
{ menu_item_id: 9, quantity: 2, customization_notes: ['Via Voice assistant'] },
{ menu_item_id: 11, quantity: 3 }
];
await api.placeOrder(items);
loadKDS();
} catch (e) {
console.error('Failed to place voice order', e);
}
};
const handleStaffLoginSuccess = (role: 'kitchen' | 'manager') => {
setStaffRole(role);
setAppMode('staff');
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{demoActive && (
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
justifyContent: 'center',
fontWeight: 800,
py: 0.5,
fontSize: '0.8rem',
position: 'sticky',
top: 0,
zIndex: 2000,
background: 'linear-gradient(90deg, #d32f2f 0%, #ef5350 100%)',
}}
>
Running in Demo Mode (Mock Backend). Start restroai-backend locally to connect to live DB.
</Alert>
)}
{appMode === 'staff' ? (
<AdminDesktopShell
role={staffRole}
orders={orders}
onStatusChange={handleStatusChange}
onToggleItem={handleToggleItem}
onAddSampleOrder={handleAddSampleOrder}
onAddOrderFromVoice={handleAddOrderFromVoice}
onLogout={() => setAppMode('customer')}
/>
) : (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2' }}>
{customerView === 'landing' && (
<TableLandingView
onStartOrdering={handleStartOrdering}
onOpenStaffLogin={() => setStaffLoginOpen(true)}
onOpenVoice={() => setVoiceModalOpen(true)}
/>
)}
{customerView === 'menu' && (
<MenuBrowseView
cart={cart}
onUpdateCart={handleUpdateCart}
onOpenCustomization={(item) => setCustomizingItem(item)}
onOpenVoice={() => setVoiceModalOpen(true)}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'cart' && (
<CartReviewView
cart={cart}
onUpdateCart={handleUpdateCart}
onPlaceOrder={handlePlaceOrder}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'status' && (
<OrderStatusView onNavigate={(v) => setCustomerView(v as CustomerView)} />
)}
{customerView === 'bill' && (
<BillPaymentView onNavigate={(v) => setCustomerView(v as CustomerView)} />
)}
{/* Item Customization Dialog */}
<CustomizationModal
open={!!customizingItem}
item={customizingItem}
onClose={() => setCustomizingItem(null)}
onConfirm={handleCustomizationConfirm}
/>
{/* AI Voice Assistant Modal (bottom sheet) */}
<VoiceAssistantModal
open={voiceModalOpen}
onClose={() => setVoiceModalOpen(false)}
onAddToCart={handleUpdateCart}
/>
</Box>
)}
{/* Staff Login Modal */}
<StaffLoginModal
open={staffLoginOpen}
onClose={() => setStaffLoginOpen(false)}
onLoginSuccess={handleStaffLoginSuccess}
/>
</ThemeProvider>
);
};
export default App;
/** @deprecated Use CustomerApp from src/apps/customer — kept for tooling that imports ./App */
export { CustomerApp as default, CustomerApp as App } from './apps/customer/App';

441
src/apps/customer/App.tsx Normal file
View File

@ -0,0 +1,441 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
import { theme } from '@restroai/ui';
import {
TableLandingView,
type GuestTableOption,
} from '../../components/customer/TableLandingView';
import { MenuBrowseView } from '../../components/customer/MenuBrowseView';
import { CustomizationModal } from '../../components/customer/CustomizationModal';
import { CartReviewView } from '../../components/customer/CartReviewView';
import { OrderStatusView } from '../../components/customer/OrderStatusView';
import { BillPaymentView } from '../../components/customer/BillPaymentView';
import { VoiceAssistantModal } from '../../components/customer/VoiceAssistantModal';
import type { MenuItem } from '../../data/menuData';
import { MENU_ITEMS } from '../../data/menuData';
import {
api,
flattenPublicMenu,
isDemoMode,
setDemoModeChangeCallback,
} from '../../services/api';
import { LocaleProvider, useLocale } from '../../i18n/LocaleContext';
import {
toSessionLanguage,
uiLangToLocale,
} from '../../i18n/messages';
import { debugLog, maskToken } from '../../utils/debugLog';
type CustomerView = 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice';
type UrlTableBinding =
| { mode: 'qr'; tableId: number }
| { mode: 'none' };
/** QR `/scan/{id}` locks the table. Bare `?table=` is a hint only (walk-in can change). */
const parseUrlTableBinding = (): UrlTableBinding => {
const path = window.location.pathname;
const match = path.match(/\/scan\/(\d+)/);
if (match) {
return { mode: 'qr', tableId: parseInt(match[1], 10) };
}
return { mode: 'none' };
};
const hintTableFromQuery = (): string | null => {
const params = new URLSearchParams(window.location.search);
const tableParam = params.get('table');
if (tableParam && tableParam.trim()) return tableParam.trim();
// Tolerate malformed links like ?table-15 (hyphen instead of =)
const raw = window.location.search.replace(/^\?/, '');
const malformed = raw.match(/(?:^|&)table-(\d+)(?:&|$)/i);
if (malformed) return malformed[1];
return null;
};
const syncTableQuery = (tableNumber: string) => {
const url = new URL(window.location.href);
url.searchParams.set('table', tableNumber);
window.history.replaceState({}, '', url.toString());
};
const CustomerAppInner: React.FC = () => {
const { setLocale, t } = useLocale();
const [customerView, setCustomerView] = useState<CustomerView>('landing');
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
const [demoActive, setDemoActive] = useState(isDemoMode);
const [cart, setCart] = useState<{ [itemId: string]: number }>({});
const [cartNotes, setCartNotes] = useState<{ [itemId: string]: string }>({});
const [lastPlacedOrder, setLastPlacedOrder] = useState<
{ name: string; qty: number; price: number; notes?: string }[]
>(() => {
try {
const raw = sessionStorage.getItem('customer_last_order_items');
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
});
const [customizingItem, setCustomizingItem] = useState<MenuItem | null>(null);
const [menuItems, setMenuItems] = useState<MenuItem[]>(MENU_ITEMS);
const [offline, setOffline] = useState(!navigator.onLine);
const [tableLocked, setTableLocked] = useState(false);
const [selectedTable, setSelectedTable] = useState<GuestTableOption | null>(null);
const [availableTables, setAvailableTables] = useState<GuestTableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(true);
const [tablesError, setTablesError] = useState<string | null>(null);
useEffect(() => {
setDemoActive(isDemoMode);
setDemoModeChangeCallback((demo) => setDemoActive(demo));
}, []);
useEffect(() => {
const on = () => setOffline(false);
const off = () => setOffline(true);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
useEffect(() => {
let cancelled = false;
const loadTables = async () => {
setTablesLoading(true);
setTablesError(null);
const binding = parseUrlTableBinding();
const hint = hintTableFromQuery();
try {
// For QR lock we may need the assigned table even if currently occupied.
const availableOnly = binding.mode !== 'qr';
const rows = await api.getPublicTables({ availableOnly });
if (cancelled) return;
const options: GuestTableOption[] = rows.map((r) => ({
id: r.id,
table_number: r.table_number,
capacity: r.capacity,
status: r.status,
}));
setAvailableTables(
availableOnly ? options : options.filter((o) => o.status === 'available')
);
if (binding.mode === 'qr') {
setTableLocked(true);
const locked =
options.find((o) => o.id === binding.tableId) ||
({
id: binding.tableId,
table_number: String(binding.tableId),
capacity: 0,
status: 'unknown',
} satisfies GuestTableOption);
setSelectedTable(locked);
syncTableQuery(locked.table_number);
} else {
setTableLocked(false);
const preferred =
(hint &&
options.find(
(o) =>
o.table_number === hint ||
o.table_number === `T${hint}` ||
String(o.id) === hint
)) ||
options.find((o) => o.table_number === '12') ||
options[0] ||
null;
setSelectedTable(preferred);
if (preferred) syncTableQuery(preferred.table_number);
}
} catch (err) {
if (cancelled) return;
console.warn('Public tables fetch failed', err);
setTablesError('Could not load tables from server.');
setTableLocked(binding.mode === 'qr');
if (binding.mode === 'qr') {
setSelectedTable({
id: binding.tableId,
table_number: String(binding.tableId),
capacity: 0,
status: 'unknown',
});
}
} finally {
if (!cancelled) setTablesLoading(false);
}
};
void loadTables();
return () => {
cancelled = true;
};
}, []);
const handleSelectTable = useCallback((table: GuestTableOption) => {
if (parseUrlTableBinding().mode === 'qr') return;
setSelectedTable(table);
syncTableQuery(table.table_number);
// Changing table before session — clear any stale guest token.
sessionStorage.removeItem('customer_session_token');
sessionStorage.removeItem('customer_session_id');
sessionStorage.removeItem('customer_table_id');
sessionStorage.removeItem('customer_table_number');
sessionStorage.removeItem('customer_restaurant_id');
}, []);
const handleUpdateCart = (itemId: string, quantity: number) => {
setCart((prev) => {
const copy = { ...prev };
if (quantity <= 0) delete copy[itemId];
else copy[itemId] = quantity;
return copy;
});
if (quantity <= 0) {
setCartNotes((prev) => {
const copy = { ...prev };
delete copy[itemId];
return copy;
});
}
};
const handleCustomizationConfirm = (item: MenuItem, quantity: number, notes: string) => {
const id = String(item.id);
handleUpdateCart(id, (cart[id] || 0) + quantity);
if (notes?.trim()) {
setCartNotes((prev) => ({
...prev,
[id]: prev[id] ? `${prev[id]} · ${notes.trim()}` : notes.trim(),
}));
}
};
const handleStartOrdering = async (language: 'english' | 'hindi' | 'hinglish') => {
const locale = uiLangToLocale(language);
setLocale(locale);
if (!selectedTable) {
debugLog.warn('session', 'start ordering blocked — no table selected');
alert(t('selectTable'));
return false;
}
debugLog.info('session', 'startSession begin', {
tableId: selectedTable.id,
tableNumber: selectedTable.table_number,
language: toSessionLanguage(locale),
});
try {
const started = await api.startSession(
selectedTable.id,
toSessionLanguage(locale),
selectedTable.table_number,
);
debugLog.info('session', 'startSession ok', {
sessionId: started?.session_id,
tableId: started?.table_id,
token: maskToken(started?.session_token),
});
try {
const catalog = await api.getPublicMenu();
const items = flattenPublicMenu(catalog);
if (items.length) setMenuItems(items);
debugLog.info('session', 'public menu loaded', { items: items.length });
} catch (menuErr) {
console.warn('Public menu fetch failed; using local catalog fallback', menuErr);
setMenuItems(MENU_ITEMS);
}
setCustomerView('menu');
return true;
} catch (err) {
debugLog.error('session', 'startSession failed', {
error: err instanceof Error ? err.message : String(err),
});
console.warn('Could not initialize session', err);
alert(
err instanceof Error
? err.message
: `Could not start session for table ${selectedTable.table_number}`
);
return false;
}
};
const openVoiceAssistant = async () => {
const sessionTableId = sessionStorage.getItem('customer_table_id');
const needsFreshSession =
!sessionStorage.getItem('customer_session_token') ||
(selectedTable != null && sessionTableId !== String(selectedTable.id));
debugLog.info('voice', 'openVoiceAssistant', {
needsFreshSession,
selectedTableId: selectedTable?.id,
sessionTableId,
hasToken: Boolean(sessionStorage.getItem('customer_session_token')),
});
if (needsFreshSession) {
const ok = await handleStartOrdering('english');
if (!ok || !sessionStorage.getItem('customer_session_token')) {
debugLog.error('voice', 'openVoiceAssistant aborted — session missing');
return;
}
}
setVoiceModalOpen(true);
};
const handlePlaceOrder = async (orderNotes: string) => {
try {
const summary = Object.entries(cart).map(([id, qty]) => {
const item = menuItems.find((m) => String(m.id) === id);
const noteParts = [cartNotes[id], orderNotes].filter((n) => n && n.trim());
return {
name: item?.name || `Item ${id}`,
qty,
price: item?.price || 0,
notes: noteParts.join(' · ') || undefined,
};
});
const items = Object.entries(cart).map(([id, qty]) => {
const notes: string[] = [];
if (cartNotes[id]?.trim()) notes.push(cartNotes[id].trim());
if (orderNotes.trim()) notes.push(orderNotes.trim());
return {
menu_item_id: Number(id),
quantity: qty,
customization_notes: notes,
};
});
await api.placeOrder(items);
setLastPlacedOrder(summary);
sessionStorage.setItem('customer_last_order_items', JSON.stringify(summary));
setCart({});
setCartNotes({});
setCustomerView('status');
} catch (err) {
alert('Failed to place order: ' + err);
}
};
const syncCartFromAi = useCallback((lines: { menu_item_id: number; quantity: number; notes?: string[] }[]) => {
const next: { [itemId: string]: number } = {};
const nextNotes: { [itemId: string]: string } = {};
for (const line of lines) {
if (line.quantity <= 0) continue;
next[String(line.menu_item_id)] = line.quantity;
if (line.notes?.length) nextNotes[String(line.menu_item_id)] = line.notes.join(', ');
}
setCart(next);
setCartNotes(nextNotes);
}, []);
const openStaffDashboard = () => {
window.location.href = '/staff.html';
};
return (
<>
{demoActive && (
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
justifyContent: 'center',
fontWeight: 800,
py: 0.5,
fontSize: '0.8rem',
position: 'sticky',
top: 0,
zIndex: 2000,
background: 'linear-gradient(90deg, #d32f2f 0%, #ef5350 100%)',
}}
>
Running in Demo Mode (Mock Backend). Start restroai-backend locally to connect to live DB.
</Alert>
)}
{offline && (
<Alert severity="info" sx={{ borderRadius: 0, fontWeight: 700 }}>
{t('offlineHint')}
</Alert>
)}
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2' }}>
{customerView === 'landing' && (
<TableLandingView
onStartOrdering={(lang) => void handleStartOrdering(lang)}
onOpenStaffLogin={openStaffDashboard}
onOpenVoice={() => void openVoiceAssistant()}
selectedTable={selectedTable}
tableLocked={tableLocked}
availableTables={availableTables}
tablesLoading={tablesLoading}
tablesError={tablesError}
onSelectTable={handleSelectTable}
/>
)}
{customerView === 'menu' && (
<MenuBrowseView
cart={cart}
cartNotes={cartNotes}
menuItems={menuItems}
onUpdateCart={handleUpdateCart}
onOpenCustomization={(item) => setCustomizingItem(item)}
onOpenVoice={() => void openVoiceAssistant()}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'cart' && (
<CartReviewView
cart={cart}
cartNotes={cartNotes}
menuItems={menuItems}
onUpdateCart={handleUpdateCart}
onPlaceOrder={handlePlaceOrder}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'status' && (
<OrderStatusView
placedItems={lastPlacedOrder}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'bill' && (
<BillPaymentView onNavigate={(v) => setCustomerView(v as CustomerView)} />
)}
<CustomizationModal
open={!!customizingItem}
item={customizingItem}
onClose={() => setCustomizingItem(null)}
onConfirm={handleCustomizationConfirm}
/>
<VoiceAssistantModal
open={voiceModalOpen}
onClose={() => setVoiceModalOpen(false)}
onAddToCart={handleUpdateCart}
onSyncCart={syncCartFromAi}
/>
</Box>
</>
);
};
export const CustomerApp: React.FC = () => (
<ThemeProvider theme={theme}>
<CssBaseline />
<LocaleProvider>
<CustomerAppInner />
</LocaleProvider>
</ThemeProvider>
);
export default CustomerApp;

View File

@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../../index.css';
import { CustomerApp } from './App';
// Service worker is registered in production builds only (vite-plugin-pwa).
if (import.meta.env.PROD) {
void import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true });
});
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CustomerApp />
</StrictMode>,
);

379
src/apps/staff/App.tsx Normal file
View File

@ -0,0 +1,379 @@
import React, { useEffect, useState, useCallback } from 'react';
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
import { theme } from '@restroai/ui';
import { StaffLoginModal } from '../../components/admin/StaffLoginModal';
import { AdminDesktopShell } from '../../components/admin/AdminDesktopShell';
import type { StaffRole } from '../../components/admin/StaffLoginModal';
import type { KDSOrder, OrderStatus } from '../../types';
import type { MenuItem } from '../../data/menuData';
import { MENU_ITEMS } from '../../data/menuData';
import {
api,
clearTokens,
getAccessToken,
isDemoMode,
RestroWebSocket,
setDemoModeChangeCallback,
subscribeToWsEvents,
type WsConnectionState,
} from '../../services/api';
type RestaurantOption = {
id: number;
name: string;
is_home?: boolean;
is_active?: boolean;
};
const elapsedMinutes = (iso?: string) => {
if (!iso) return 0;
const ms = Date.now() - new Date(iso).getTime();
return Math.max(0, Math.floor(ms / 60000));
};
/** Column placement must follow item kds_status (buttons patch items, not order.status alone). */
const deriveBoardStatus = (
items: { kds_status?: string }[],
orderStatus?: string,
): OrderStatus => {
const statuses = items.map((i) => String(i.kds_status || 'queued'));
if (statuses.length) {
const allServed = statuses.every((s) => s === 'served');
if (allServed) return 'served';
const allReadyOrServed = statuses.every((s) => s === 'ready' || s === 'served');
if (allReadyOrServed) return 'ready';
if (statuses.some((s) => s === 'in_prep') || statuses.some((s) => s === 'ready' || s === 'served')) {
return 'preparing';
}
return 'pending';
}
if (orderStatus === 'served') return 'served';
if (orderStatus === 'ready') return 'ready';
if (orderStatus === 'in_preparation' || orderStatus === 'preparing') return 'preparing';
return 'pending';
};
const mapBoardOrders = (ordersArray: any[], menuItems: MenuItem[]): KDSOrder[] =>
ordersArray.map((ord: any) => {
const rawItems = ord.items || [];
const overallStatus = deriveBoardStatus(rawItems, ord.status);
const placedAt = ord.createdAt || ord.placed_at || new Date().toISOString();
const tableNumber =
ord.tableNumber ||
(ord.table_number != null && ord.table_number !== '' ? String(ord.table_number) : undefined);
const channel = String(ord.channel || '');
const orderType: KDSOrder['orderType'] =
ord.orderType ||
(channel.includes('delivery')
? 'Delivery'
: tableNumber
? 'Dine-In'
: 'Takeaway');
return {
id: String(ord.id || ord.order_id),
ticketNumber: ord.ticketNumber || String(ord.id || ord.order_id),
tableNumber,
orderType,
status: overallStatus,
createdAt: placedAt,
timeElapsedMinutes: ord.timeElapsedMinutes ?? elapsedMinutes(placedAt),
priority: ord.priority || 'normal',
serverName: ord.serverName || undefined,
totalAmount:
ord.totalAmount ||
Number(ord.subtotal || 0) ||
rawItems.reduce(
(sum: number, it: any) => sum + Number(it.unit_price || it.price || 0) * Number(it.quantity || 0),
0,
),
items: rawItems.map((it: any) => {
const catalog = menuItems.find((m) => m.id === Number(it.menu_item_id));
const name = it.name || catalog?.name || `Dish #${it.menu_item_id}`;
return {
id: String(it.id),
name,
quantity: it.quantity,
price: it.price || (it.unit_price != null ? Number(it.unit_price) : catalog?.price || 0),
completed: it.kds_status === 'ready' || it.kds_status === 'served',
dietary: (catalog?.dietary ||
(/chicken|mutton|fish|prawn|egg|keema|kebab/i.test(name) ? 'non-veg' : 'veg')) as KDSOrder['items'][0]['dietary'],
};
}),
};
});
export const StaffApp: React.FC = () => {
const [authed, setAuthed] = useState<boolean>(() => Boolean(getAccessToken()));
const [loginOpen, setLoginOpen] = useState<boolean>(() => !getAccessToken());
const [staffRole, setStaffRole] = useState<StaffRole>('chef');
const [restaurantId, setRestaurantId] = useState<number>(1);
const [restaurants, setRestaurants] = useState<RestaurantOption[]>([]);
const [orders, setOrders] = useState<KDSOrder[]>([]);
const [menuItems] = useState<MenuItem[]>(MENU_ITEMS);
const [demoActive, setDemoActive] = useState(isDemoMode);
const [wsState, setWsState] = useState<WsConnectionState>('idle');
const [actionError, setActionError] = useState<string | null>(null);
const [busyOrderId, setBusyOrderId] = useState<string | null>(null);
const hydrateMe = useCallback(async () => {
const me = await api.getMe();
const role = String(me?.role || '').toLowerCase();
if (role === 'manager' || role === 'chef' || role === 'waiter' || role === 'cashier') {
setStaffRole(role);
}
if (me.restaurant_id) setRestaurantId(me.restaurant_id);
if (Array.isArray(me.restaurants) && me.restaurants.length) {
setRestaurants(me.restaurants);
} else if (me.restaurant_id) {
setRestaurants([{ id: me.restaurant_id, name: `Restaurant #${me.restaurant_id}`, is_active: true }]);
}
}, []);
useEffect(() => {
setDemoModeChangeCallback((demo) => setDemoActive(demo));
if (authed) {
hydrateMe().catch(() => {
/* keep defaults */
});
}
}, [authed, hydrateMe]);
const loadKDS = useCallback(async () => {
try {
const data = await api.getKDSBoard(restaurantId);
const ordersArray = Array.isArray(data) ? data : data?.orders || [];
setOrders(mapBoardOrders(ordersArray, menuItems));
setActionError(null);
} catch (err) {
console.error('Failed to reload KDS board', err);
setActionError(err instanceof Error ? err.message : 'Failed to load KDS board');
}
}, [menuItems, restaurantId]);
useEffect(() => {
if (!authed) return;
loadKDS();
const ws = new RestroWebSocket('kds', String(restaurantId));
ws.onStateChange = (state) => setWsState(state);
ws.onReconnected = () => {
loadKDS();
};
ws.connect();
const unsubscribe = subscribeToWsEvents((event) => {
if (event.type === 'kds.item_updated') {
loadKDS();
}
});
const tick = window.setInterval(() => {
setOrders((prev) =>
prev.map((o) => ({ ...o, timeElapsedMinutes: elapsedMinutes(o.createdAt) })),
);
}, 30000);
return () => {
ws.close();
unsubscribe();
window.clearInterval(tick);
};
}, [authed, loadKDS, restaurantId]);
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
const statusMap: Record<OrderStatus, string> = {
pending: 'queued',
preparing: 'in_prep',
ready: 'ready',
served: 'served',
};
const kdsStatus = statusMap[newStatus];
const order = orders.find((o) => o.id === orderId);
if (!order) return;
setBusyOrderId(orderId);
// Optimistic move so the board feels live even before reload.
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? {
...o,
status: newStatus,
items: o.items.map((it) => ({
...it,
completed: newStatus === 'ready' || newStatus === 'served',
})),
}
: o,
),
);
try {
await Promise.all(order.items.map((item) => api.updateOrderItemStatus(item.id, kdsStatus)));
await loadKDS();
} catch (err) {
console.error('Failed to change status', err);
setActionError(err instanceof Error ? err.message : 'Failed to update order status');
await loadKDS();
} finally {
setBusyOrderId(null);
}
};
const handleToggleItem = async (orderId: string, itemId: string) => {
const order = orders.find((o) => o.id === orderId);
const item = order?.items.find((i) => i.id === itemId);
if (!item) return;
const nextStatus = item.completed ? 'in_prep' : 'ready';
setOrders((prev) =>
prev.map((o) => {
if (o.id !== orderId) return o;
const items = o.items.map((it) =>
it.id === itemId ? { ...it, completed: !item.completed } : it,
);
return { ...o, items, status: deriveBoardStatus(
items.map((it) => ({ kds_status: it.completed ? 'ready' : 'in_prep' })),
o.status,
) };
}),
);
try {
await api.updateOrderItemStatus(itemId, nextStatus);
await loadKDS();
} catch (err) {
console.error('Failed to toggle item', err);
setActionError(err instanceof Error ? err.message : 'Failed to update item');
await loadKDS();
}
};
const handleAddSampleOrder = async () => {
try {
setActionError(null);
await api.createKdsTestOrder(restaurantId);
await loadKDS();
} catch (e) {
console.error('Failed to add sample order', e);
setActionError(e instanceof Error ? e.message : 'Failed to create test order');
}
};
const handleAddOrderFromVoice = async (_transcript: string) => {
try {
await api.createKdsTestOrder(restaurantId);
await loadKDS();
} catch (e) {
console.error('Failed to place voice order', e);
setActionError(e instanceof Error ? e.message : 'Failed to place voice order');
}
};
const handleLoginSuccess = async (role: StaffRole) => {
setStaffRole(role);
setAuthed(true);
setLoginOpen(false);
try {
await hydrateMe();
} catch {
/* ignore */
}
};
const handleSwitchRestaurant = async (nextId: number) => {
if (nextId === restaurantId) return;
try {
await api.switchRestaurant(nextId);
setRestaurantId(nextId);
await hydrateMe();
} catch (err) {
console.error('Failed to switch restaurant', err);
alert('Could not switch restaurant: ' + err);
}
};
const handleLogout = () => {
clearTokens();
setAuthed(false);
setLoginOpen(true);
setWsState('closed');
setRestaurants([]);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{demoActive && (
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
justifyContent: 'center',
fontWeight: 800,
py: 0.5,
fontSize: '0.8rem',
position: 'sticky',
top: 0,
zIndex: 2000,
}}
>
Running in Demo Mode (Mock Backend).
</Alert>
)}
{actionError && (
<Alert
severity="error"
onClose={() => setActionError(null)}
sx={{ borderRadius: 0, fontWeight: 700 }}
>
{actionError}
</Alert>
)}
{authed ? (
<AdminDesktopShell
role={staffRole}
restaurantId={restaurantId}
restaurants={restaurants}
onSwitchRestaurant={handleSwitchRestaurant}
orders={orders}
onStatusChange={handleStatusChange}
onToggleItem={handleToggleItem}
onAddSampleOrder={handleAddSampleOrder}
onAddOrderFromVoice={handleAddOrderFromVoice}
onLogout={handleLogout}
wsState={wsState}
busyOrderId={busyOrderId}
/>
) : (
<Box
sx={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
bgcolor: '#1a1c1c',
color: '#fff',
p: 3,
}}
>
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Box sx={{ fontWeight: 900, fontSize: '1.75rem', color: '#ffb5a0' }}>RestroAI</Box>
<Box sx={{ opacity: 0.7, fontWeight: 600 }}>Staff dashboard</Box>
</Box>
<StaffLoginModal
open={loginOpen}
onClose={() => {
window.location.href = '/';
}}
onLoginSuccess={handleLoginSuccess}
/>
</Box>
)}
</ThemeProvider>
);
};
export default StaffApp;

11
src/apps/staff/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../../index.css';
import './staff.css';
import { StaffApp } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<StaffApp />
</StrictMode>,
);

23
src/apps/staff/staff.css Normal file
View File

@ -0,0 +1,23 @@
/* Staff dashboard: full-bleed shell (override Vite demo #root constraints) */
html,
body,
#root {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
text-align: left;
border: none;
min-height: 100vh;
min-height: 100svh;
}
#root {
display: block;
box-sizing: border-box;
}
body {
overflow-x: hidden;
background: #f4f5f7;
}

View File

@ -14,6 +14,11 @@ import {
Button,
Avatar,
Divider,
Alert,
FormControl,
Select,
MenuItem,
InputLabel,
} from '@mui/material';
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
import BarChartIcon from '@mui/icons-material/BarChart';
@ -28,6 +33,9 @@ import InventoryIcon from '@mui/icons-material/Inventory';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import BadgeIcon from '@mui/icons-material/Badge';
import StarHalfIcon from '@mui/icons-material/StarHalf';
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
import WifiOffIcon from '@mui/icons-material/WifiOff';
import WifiIcon from '@mui/icons-material/Wifi';
import { KDSKanban } from '../kds/KDSKanban';
import { AnalyticsDashboard } from '../analytics/AnalyticsDashboard';
@ -39,21 +47,30 @@ import { InventoryView } from './InventoryView';
import { SuppliersView } from './SuppliersView';
import { StaffShiftsView } from './StaffShiftsView';
import { FeedbackInboxView } from './FeedbackInboxView';
import { MenuManagementView } from './MenuManagementView';
import { LowStockNotifier } from './LowStockNotifier';
import type { KDSOrder, OrderStatus } from '../../types';
import type { StaffRole } from './StaffLoginModal';
interface AdminDesktopShellProps {
role: 'kitchen' | 'manager';
role: StaffRole;
orders: KDSOrder[];
restaurantId: number;
restaurants?: { id: number; name: string; is_home?: boolean; is_active?: boolean }[];
onSwitchRestaurant?: (restaurantId: number) => void;
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
onAddSampleOrder: () => void;
onAddOrderFromVoice: (transcript: string) => void;
onLogout: () => void;
wsState?: 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
busyOrderId?: string | null;
}
type TabType =
| 'kds'
| 'menu'
| 'tables'
| 'billing'
| 'reservations'
@ -64,56 +81,78 @@ type TabType =
| 'analytics'
| 'voice';
const defaultTabForRole = (role: StaffRole): TabType => {
if (role === 'manager') return 'analytics';
if (role === 'cashier') return 'billing';
if (role === 'waiter') return 'tables';
return 'kds';
};
export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
role,
orders,
restaurantId,
restaurants = [],
onSwitchRestaurant,
onStatusChange,
onToggleItem,
onAddSampleOrder,
onAddOrderFromVoice,
onLogout,
wsState = 'idle',
busyOrderId = null,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(
role === 'manager' ? 'analytics' : 'kds'
);
const [activeTab, setActiveTab] = useState<TabType>(defaultTabForRole(role));
const pendingCount = orders.filter((o) => o.status === 'pending').length;
const drawerWidth = 260;
const drawerWidth = 232;
// Sidebar list configurations
const menuItems = [
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon />, roles: ['kitchen', 'manager'] },
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon />, roles: ['manager'] },
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon />, roles: ['manager'] },
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon />, roles: ['manager'] },
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon />, roles: ['kitchen', 'manager'] },
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon />, roles: ['manager'] },
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon />, roles: ['manager'] },
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon />, roles: ['manager'] },
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon />, roles: ['manager'] },
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon />, roles: ['kitchen', 'manager'] },
// Sidebar list configurations — matches roadmap §2.4
const menuItems: { id: TabType; label: string; icon: React.ReactNode; roles: StaffRole[] }[] = [
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
{ id: 'menu', label: 'Menu Management', icon: <RestaurantMenuIcon fontSize="small" />, roles: ['manager'] },
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon fontSize="small" />, roles: ['waiter', 'manager'] },
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon fontSize="small" />, roles: ['cashier', 'waiter', 'manager'] },
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon fontSize="small" />, roles: ['waiter', 'manager'] },
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon fontSize="small" />, roles: ['chef', 'manager'] },
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon fontSize="small" />, roles: ['manager'] },
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon fontSize="small" />, roles: ['manager'] },
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon fontSize="small" />, roles: ['manager'] },
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon fontSize="small" />, roles: ['manager'] },
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
];
const filteredMenuItems = menuItems.filter((item) => item.roles.includes(role));
const getPageTitle = () => {
switch (activeTab) {
case 'kds': return 'Kitchen Display System (KDS) Live Kanban';
case 'tables': return 'Table Floor & Session Planner';
case 'billing': return 'Cashier Billing Desk & Payments';
case 'reservations': return 'Diner Reservations Book';
case 'inventory': return 'Kitchen Raw Stocks & Recipes Linking';
case 'suppliers': return 'Merchant Suppliers & Purchase Orders';
case 'staff': return 'Employee Attendance & Shift Roster';
case 'feedback': return 'Customer Feedback Rating Reviews';
case 'analytics': return 'Executive Business Analytics & Trends';
case 'voice': return 'Staff AI Voice Assistant Terminal';
default: return 'Management Portal';
case 'kds': return 'KDS Live Kanban';
case 'menu': return 'Menu Management';
case 'tables': return 'Tables & Floor';
case 'billing': return 'Billing Desk';
case 'reservations': return 'Reservations';
case 'inventory': return 'Kitchen Stock & Recipes';
case 'suppliers': return 'Suppliers & POs';
case 'staff': return 'Staff & Shifts';
case 'feedback': return 'Customer Feedback';
case 'analytics': return 'Business Intelligence';
case 'voice': return 'AI Voice Terminal';
default: return 'Staff Portal';
}
};
const roleLabel =
role === 'manager'
? 'Admin Manager'
: role === 'cashier'
? 'Cashier'
: role === 'waiter'
? 'Waiter'
: 'Chef';
return (
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: '#f4f5f7' }}>
<Box sx={{ display: 'flex', width: '100%', minHeight: '100vh', bgcolor: '#f4f5f7', textAlign: 'left' }}>
<LowStockNotifier enabled={role === 'manager' || role === 'chef'} autoNotify={role === 'manager'} />
{/* Desktop Sidebar Navigation */}
<Drawer
variant="permanent"
@ -126,84 +165,122 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
bgcolor: '#1a1c1c',
color: '#ffffff',
borderRight: '1px solid #2f3131',
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
}}
>
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', gap: 1.25, flexShrink: 0 }}>
<Box
sx={{
width: 40,
height: 40,
width: 36,
height: 36,
borderRadius: '8px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
flexShrink: 0,
}}
>
<LocalDiningIcon />
<LocalDiningIcon fontSize="small" />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.15, fontSize: '1.05rem' }}>
RestroAI
</Typography>
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.65rem', fontWeight: 700 }}>
DESKTOP STAFF PORTAL
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.04em' }}>
STAFF PORTAL
</Typography>
</Box>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Box sx={{ p: 2 }}>
<Box sx={{ px: 1.5, py: 1.5, flexShrink: 0 }}>
<Chip
avatar={<Avatar sx={{ bgcolor: '#ac2d00' }}>{role === 'manager' ? 'M' : 'K'}</Avatar>}
label={role === 'manager' ? 'Role: Restaurant Manager' : 'Role: Kitchen Head Chef'}
avatar={
<Avatar sx={{ bgcolor: '#ac2d00', width: 24, height: 24, fontSize: '0.75rem' }}>
{role === 'manager' ? 'M' : role === 'cashier' ? 'C' : role === 'waiter' ? 'W' : 'K'}
</Avatar>
}
label={roleLabel}
size="small"
sx={{
bgcolor: 'rgba(255,255,255,0.08)',
color: '#ffffff',
fontWeight: 700,
width: '100%',
justifyContent: 'flex-start',
mb: restaurants.length > 1 ? 1.25 : 0,
}}
/>
{restaurants.length > 1 && onSwitchRestaurant && (
<FormControl fullWidth size="small">
<InputLabel id="restaurant-picker-label" sx={{ color: '#c6c6cd' }}>
Location
</InputLabel>
<Select
labelId="restaurant-picker-label"
label="Location"
value={restaurantId}
onChange={(e) => onSwitchRestaurant(Number(e.target.value))}
sx={{
color: '#fff',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.2)' },
'.MuiSvgIcon-root': { color: '#ffb5a0' },
}}
>
{restaurants.map((r) => (
<MenuItem key={r.id} value={r.id}>
{r.name}
{r.is_home ? ' (home)' : ''}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
<List sx={{ px: 1, overflowY: 'auto', flexGrow: 1 }}>
<List sx={{ px: 1, py: 0.5, overflowY: 'auto', flexGrow: 1, minHeight: 0 }}>
{filteredMenuItems.map((item) => (
<ListItem disablePadding sx={{ mb: 0.5 }} key={item.id}>
<ListItem disablePadding sx={{ mb: 0.25 }} key={item.id}>
<ListItemButton
selected={activeTab === item.id}
onClick={() => setActiveTab(item.id as TabType)}
dense
sx={{
borderRadius: '8px',
py: 0.75,
'&.Mui-selected': { bgcolor: '#ac2d00', color: '#ffffff' },
'&.Mui-selected:hover': { bgcolor: '#872100' },
}}
>
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 38 }}>
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 34 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={
<Typography sx={{ fontWeight: activeTab === item.id ? 800 : 500, fontSize: '0.9rem' }}>
<Typography sx={{ fontWeight: activeTab === item.id ? 700 : 500, fontSize: '0.8125rem', lineHeight: 1.3 }}>
{item.label}
</Typography>
}
/>
{item.id === 'kds' && pendingCount > 0 && (
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20 }} />
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20, ml: 0.5 }} />
)}
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ mt: 'auto', p: 2 }}>
<Box sx={{ p: 1.5, flexShrink: 0, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Button
fullWidth
variant="outlined"
size="small"
onClick={onLogout}
startIcon={<LogoutIcon />}
sx={{
@ -217,46 +294,97 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
},
}}
>
Switch to Diner Mode
Log out
</Button>
</Box>
</Drawer>
{/* Main Content Area */}
<Box component="main" sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2' }}>
<Toolbar sx={{ justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>
<Box
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
{(wsState === 'reconnecting' || wsState === 'connecting') && (
<Alert
severity="warning"
icon={<WifiOffIcon />}
sx={{ borderRadius: 0, fontWeight: 700, py: 0.5 }}
>
Reconnecting to kitchen board will refresh when live.
</Alert>
)}
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2', flexShrink: 0 }}>
<Toolbar
sx={{
minHeight: { xs: 56, sm: 60 },
px: { xs: 2, md: 3 },
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
variant="h6"
sx={{
fontWeight: 800,
color: '#ac2d00',
fontSize: { xs: '1rem', md: '1.15rem' },
lineHeight: 1.25,
minWidth: 0,
flex: '1 1 auto',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{getPageTitle()}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5 }, flexShrink: 0 }}>
<Chip
label="System Status: LIVE ONLINE"
color="success"
icon={wsState === 'open' ? <WifiIcon /> : <WifiOffIcon />}
label={
wsState === 'open'
? 'Kitchen live'
: wsState === 'reconnecting' || wsState === 'connecting'
? 'Reconnecting…'
: 'WS idle'
}
color={wsState === 'open' ? 'success' : 'warning'}
size="small"
sx={{ fontWeight: 800 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AccountCircleIcon sx={{ color: '#ac2d00' }} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Logged in as {role === 'manager' ? 'Admin Manager' : 'Chef Rahul S.'}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AccountCircleIcon sx={{ color: '#ac2d00', fontSize: 22 }} />
<Typography variant="body2" sx={{ fontWeight: 700, whiteSpace: 'nowrap', display: { xs: 'none', sm: 'block' } }}>
{roleLabel}
</Typography>
</Box>
</Box>
</Toolbar>
</AppBar>
<Box sx={{ p: 3, flexGrow: 1, overflowY: 'auto' }}>
<Box sx={{ px: { xs: 1.5, md: 2.5 }, py: { xs: 1.5, md: 2 }, flexGrow: 1, overflowY: 'auto', minHeight: 0 }}>
{activeTab === 'kds' && (
<KDSKanban
orders={orders}
busyOrderId={busyOrderId}
onStatusChange={onStatusChange}
onToggleItem={onToggleItem}
onAddSampleOrder={onAddSampleOrder}
/>
)}
{activeTab === 'menu' && <MenuManagementView />}
{activeTab === 'tables' && <TableManagementView />}
{activeTab === 'billing' && <BillingManagerView />}

View File

@ -16,6 +16,7 @@ import {
MenuItem,
Button,
Grid,
Chip,
} from '@mui/material';
import StarIcon from '@mui/icons-material/Star';
import RefreshIcon from '@mui/icons-material/Refresh';
@ -133,9 +134,30 @@ export const FeedbackInboxView: React.FC = () => {
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
{fb.comment_text || '(No comment left by guest)'}
</Typography>
{(fb.sentiment_results || []).length > 0 && (
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
{fb.sentiment_results.map((row: any) => (
<Chip
key={`${fb.id}-${row.dimension}-${row.id || row.sentiment}`}
size="small"
label={`${row.dimension}: ${row.sentiment}`}
color={
row.sentiment === 'positive'
? 'success'
: row.sentiment === 'negative'
? 'error'
: 'default'
}
sx={{ fontWeight: 700, textTransform: 'capitalize' }}
/>
))}
</Box>
)}
</Box>
}
/>
</ListItem>

View File

@ -36,10 +36,11 @@ import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import SaveIcon from '@mui/icons-material/Save';
import { api } from '../../services/api';
import { MENU_ITEMS } from '../../data/menuData';
import type { StaffMenuItem } from '../../services/api';
export const InventoryView: React.FC = () => {
const [ingredients, setIngredients] = useState<any[]>([]);
const [menuDishes, setMenuDishes] = useState<StaffMenuItem[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -65,8 +66,16 @@ export const InventoryView: React.FC = () => {
setLoading(true);
setError(null);
try {
const data = await api.getIngredients();
const [data, dishes] = await Promise.all([
api.getIngredients(),
api.listMenuItems().catch(() => []),
]);
setIngredients(Array.isArray(data) ? data : []);
const dishList = Array.isArray(dishes) ? dishes : [];
setMenuDishes(dishList);
if (dishList.length && !dishList.some((d) => d.id === selectedMenuId)) {
setSelectedMenuId(dishList[0].id);
}
} catch (err: any) {
setError(err.message || 'Failed to fetch ingredients');
} finally {
@ -271,9 +280,9 @@ export const InventoryView: React.FC = () => {
label="Select Menu Dish"
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
>
{MENU_ITEMS.map((item) => (
<MenuItem key={item.id} value={parseInt(item.id.replace('m', ''))}>
{item.name} ({item.price})
{menuDishes.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({Number(item.price)})
</MenuItem>
))}
</Select>

View File

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, Button, Snackbar } from '@mui/material';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import { api } from '../../services/api';
const POLL_MS = 5 * 60 * 1000; // every 5 minutes
const DISMISS_MS = 60 * 1000;
type LowRow = { id: number; name: string; current_stock?: number; unit?: string };
function fingerprint(rows: LowRow[]): string {
return rows
.map((r) => `${r.id}:${r.current_stock ?? ''}`)
.sort()
.join('|');
}
/**
* Periodic low-stock toast for chef/manager while the staff portal is open.
* Also can push WhatsApp/notify when stock newly crosses into low.
*/
export const LowStockNotifier: React.FC<{
enabled?: boolean;
autoNotify?: boolean;
}> = ({ enabled = true, autoNotify = false }) => {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [count, setCount] = useState(0);
const lastFp = useRef<string>('');
const dismissedUntil = useRef(0);
const check = useCallback(async () => {
if (!enabled) return;
try {
const rows = await api.getLowStockInventory();
const list: LowRow[] = Array.isArray(rows) ? rows : rows?.items || [];
setCount(list.length);
if (!list.length) {
lastFp.current = '';
return;
}
const fp = fingerprint(list);
const changed = fp !== lastFp.current;
lastFp.current = fp;
if (!changed && Date.now() < dismissedUntil.current) return;
const names = list
.slice(0, 4)
.map((r) => r.name)
.join(', ');
const extra = list.length > 4 ? ` +${list.length - 4} more` : '';
setMessage(`${list.length} low-stock item${list.length === 1 ? '' : 's'}: ${names}${extra}`);
setOpen(true);
if (autoNotify && changed) {
try {
await api.notifyLowStock();
} catch {
/* optional outbound notify */
}
}
} catch {
/* silent — board can stay up without inventory */
}
}, [autoNotify, enabled]);
useEffect(() => {
if (!enabled) return;
void check();
const id = window.setInterval(() => void check(), POLL_MS);
return () => window.clearInterval(id);
}, [check, enabled]);
if (!enabled) return null;
return (
<Snackbar
open={open}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
autoHideDuration={DISMISS_MS}
onClose={() => {
setOpen(false);
dismissedUntil.current = Date.now() + POLL_MS;
}}
>
<Alert
severity="warning"
variant="filled"
icon={<NotificationsActiveIcon />}
sx={{ fontWeight: 700, alignItems: 'center' }}
action={
<Button
color="inherit"
size="small"
sx={{ fontWeight: 800 }}
onClick={() => {
setOpen(false);
dismissedUntil.current = Date.now() + POLL_MS;
void api.notifyLowStock().catch(() => undefined);
}}
>
Notify
</Button>
}
onClose={() => {
setOpen(false);
dismissedUntil.current = Date.now() + POLL_MS;
}}
>
{message || `${count} ingredients need reorder`}
</Alert>
</Snackbar>
);
};

View File

@ -0,0 +1,373 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Grid,
IconButton,
List,
ListItemButton,
ListItemText,
Paper,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import RefreshIcon from '@mui/icons-material/Refresh';
import { api, type StaffMenuCategory, type StaffMenuItem } from '../../services/api';
export const MenuManagementView: React.FC = () => {
const [categories, setCategories] = useState<StaffMenuCategory[]>([]);
const [items, setItems] = useState<StaffMenuItem[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | 'all'>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [categoryName, setCategoryName] = useState('');
const [categoryOrder, setCategoryOrder] = useState(0);
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<StaffMenuItem | null>(null);
const [itemName, setItemName] = useState('');
const [itemPrice, setItemPrice] = useState('100');
const [itemDescription, setItemDescription] = useState('');
const [itemCategoryId, setItemCategoryId] = useState<number | ''>('');
const [itemAvailable, setItemAvailable] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cats, menuItems] = await Promise.all([
api.listMenuCategories(),
api.listMenuItems(),
]);
setCategories(Array.isArray(cats) ? cats : []);
setItems(Array.isArray(menuItems) ? menuItems : []);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load menu');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const filteredItems = useMemo(() => {
if (selectedCategoryId === 'all') return items;
return items.filter((item) => item.category_id === selectedCategoryId);
}, [items, selectedCategoryId]);
const openCreateItem = () => {
setEditingItem(null);
setItemName('');
setItemPrice('100');
setItemDescription('');
setItemCategoryId(typeof selectedCategoryId === 'number' ? selectedCategoryId : categories[0]?.id || '');
setItemAvailable(true);
setItemDialogOpen(true);
};
const openEditItem = (item: StaffMenuItem) => {
setEditingItem(item);
setItemName(item.name);
setItemPrice(String(item.price));
setItemDescription(item.description || '');
setItemCategoryId(item.category_id ?? '');
setItemAvailable(item.is_available);
setItemDialogOpen(true);
};
const saveCategory = async () => {
try {
await api.createMenuCategory({
name: categoryName.trim(),
display_order: categoryOrder,
});
setCategoryDialogOpen(false);
setCategoryName('');
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to create category');
}
};
const saveItem = async () => {
try {
const payload = {
name: itemName.trim(),
price: itemPrice,
description: itemDescription || null,
category_id: itemCategoryId === '' ? null : Number(itemCategoryId),
is_available: itemAvailable,
};
if (editingItem) {
await api.updateMenuItem(editingItem.id, payload);
} else {
await api.createMenuItem(payload);
}
setItemDialogOpen(false);
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save item');
}
};
const toggleAvailability = async (item: StaffMenuItem) => {
try {
await api.updateMenuItem(item.id, { is_available: !item.is_available });
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update availability');
}
};
const deleteItem = async (item: StaffMenuItem) => {
if (!window.confirm(`Delete "${item.name}"?`)) return;
try {
await api.deleteMenuItem(item.id);
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete item');
}
};
const deleteCategory = async (category: StaffMenuCategory) => {
if (!window.confirm(`Delete category "${category.name}"? Items keep existing but become uncategorized.`)) return;
try {
await api.deleteMenuCategory(category.id);
if (selectedCategoryId === category.id) setSelectedCategoryId('all');
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete category');
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
Menu Catalog
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button startIcon={<RefreshIcon />} onClick={() => void load()}>
Refresh
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setCategoryOrder(categories.length + 1);
setCategoryDialogOpen(true);
}}
>
Category
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreateItem}>
Item
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 3 }}>
<Paper variant="outlined" sx={{ borderRadius: 2 }}>
<List dense>
<ListItemButton
selected={selectedCategoryId === 'all'}
onClick={() => setSelectedCategoryId('all')}
>
<ListItemText primary="All items" secondary={`${items.length} dishes`} />
</ListItemButton>
{categories.map((category) => (
<ListItemButton
key={category.id}
selected={selectedCategoryId === category.id}
onClick={() => setSelectedCategoryId(category.id)}
>
<ListItemText
primary={category.name}
secondary={`order ${category.display_order}`}
/>
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation();
void deleteCategory(category);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemButton>
))}
</List>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 9 }}>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Category</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell>Available</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredItems.map((item) => (
<TableRow key={item.id} hover>
<TableCell>
<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>
<Typography variant="caption" color="text.secondary">
{item.description || '—'}
</Typography>
</TableCell>
<TableCell>
<Chip size="small" label={item.category_name || 'Uncategorized'} />
</TableCell>
<TableCell align="right">{Number(item.price).toFixed(2)}</TableCell>
<TableCell>
<Switch
checked={item.is_available}
onChange={() => void toggleAvailability(item)}
size="small"
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEditItem(item)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => void deleteItem(item)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{filteredItems.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
<Typography color="text.secondary" sx={{ py: 3, textAlign: 'center' }}>
No menu items in this category yet.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
<Dialog open={categoryDialogOpen} onClose={() => setCategoryDialogOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>New category</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label="Name"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
fullWidth
/>
<TextField
label="Display order"
type="number"
value={categoryOrder}
onChange={(e) => setCategoryOrder(Number(e.target.value))}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCategoryDialogOpen(false)}>Cancel</Button>
<Button variant="contained" disabled={!categoryName.trim()} onClick={() => void saveCategory()}>
Create
</Button>
</DialogActions>
</Dialog>
<Dialog open={itemDialogOpen} onClose={() => setItemDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingItem ? 'Edit menu item' : 'New menu item'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField label="Name" value={itemName} onChange={(e) => setItemName(e.target.value)} fullWidth />
<TextField
label="Price (INR)"
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
fullWidth
/>
<TextField
label="Description"
value={itemDescription}
onChange={(e) => setItemDescription(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<TextField
select
label="Category"
value={itemCategoryId}
onChange={(e) => setItemCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
slotProps={{
select: { native: true },
}}
>
<option value="">Uncategorized</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</TextField>
<FormControlLabel
control={
<Switch checked={itemAvailable} onChange={(e) => setItemAvailable(e.target.checked)} />
}
label="Available"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setItemDialogOpen(false)}>Cancel</Button>
<Button variant="contained" disabled={!itemName.trim()} onClick={() => void saveItem()}>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -13,15 +13,24 @@ import {
CircularProgress,
} from '@mui/material';
import LockIcon from '@mui/icons-material/Lock';
import BadgeIcon from '@mui/icons-material/Badge';
import { api } from '../../services/api';
export type StaffRole = 'chef' | 'waiter' | 'cashier' | 'manager';
interface StaffLoginModalProps {
open: boolean;
onClose: () => void;
onLoginSuccess: (role: 'kitchen' | 'manager') => void;
onLoginSuccess: (role: StaffRole) => void;
}
const normalizeRole = (role: string): StaffRole => {
const value = role.trim().toLowerCase();
if (value === 'manager') return 'manager';
if (value === 'waiter') return 'waiter';
if (value === 'cashier') return 'cashier';
return 'chef';
};
export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
open,
onClose,
@ -43,10 +52,8 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
setError('');
try {
await api.login(username, password);
// Determine role based on JWT claims or username
const role = username.includes('manager') ? 'manager' : 'kitchen';
onLoginSuccess(role);
onClose();
const me = await api.getMe();
onLoginSuccess(normalizeRole(me.role));
} catch (err: any) {
setError(err.message || 'Login failed. Please check your credentials.');
} finally {
@ -81,7 +88,7 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
Staff & Admin Portal Login
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139' }}>
Authorized Restaurant Personnel Only
Role comes from JWT via /auth/me
</Typography>
</DialogTitle>
@ -97,70 +104,62 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
QUICK AUTOFILL DEMO LOGIN:
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={<BadgeIcon fontSize="small" />}
label="Manager Account"
label="Manager"
clickable
onClick={() => fillCredentials('manager@test.com', 'managerpass')}
sx={{ fontWeight: 700, flex: 1 }}
onClick={() => fillCredentials('manager@demo.restro', 'Manager@12345')}
sx={{ fontWeight: 700 }}
/>
<Chip
icon={<BadgeIcon fontSize="small" />}
label="Chef / Waiter Account"
label="Chef"
clickable
onClick={() => fillCredentials('waiter@test.com', 'waiterpass')}
sx={{ fontWeight: 700, flex: 1 }}
onClick={() => fillCredentials('chef@demo.restro', 'Chef@12345')}
sx={{ fontWeight: 700 }}
/>
<Chip
label="Waiter"
clickable
onClick={() => fillCredentials('waiter@demo.restro', 'Waiter@12345')}
sx={{ fontWeight: 700 }}
/>
<Chip
label="Cashier"
clickable
onClick={() => fillCredentials('cashier@demo.restro', 'Cashier@12345')}
sx={{ fontWeight: 700 }}
/>
</Box>
</Box>
<TextField
label="Email Address / Username"
type="email"
placeholder="manager@test.com"
label="Email"
fullWidth
margin="dense"
value={username}
onChange={(e) => setUsername(e.target.value)}
sx={{ mb: 2 }}
disabled={loading}
autoComplete="username"
/>
<TextField
label="Password"
type="password"
placeholder="••••••••"
fullWidth
margin="dense"
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{ mb: 2.5 }}
disabled={loading}
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{
py: 1.5,
borderRadius: '9999px',
fontWeight: 800,
bgcolor: '#ac2d00',
'&:hover': { bgcolor: '#872100' },
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Access Portal'}
<DialogActions sx={{ px: 0, pt: 2 }}>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
</form>
</DialogContent>
<DialogActions sx={{ p: 2, justifyContent: 'center' }}>
<Button size="small" onClick={onClose} color="inherit" disabled={loading}>
Cancel & Return to Dining
<Button type="submit" variant="contained" disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>
{loading ? 'Signing in…' : 'Sign In'}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -41,7 +41,7 @@ export const StaffShiftsView: React.FC = () => {
const [staffName, setStaffName] = useState<string>('');
const [staffEmail, setStaffEmail] = useState<string>('');
const [staffPassword, setStaffPassword] = useState<string>('');
const [staffRoleId, setStaffRoleId] = useState<number>(2); // default waiter role_id
const [staffRoleId] = useState<number>(2); // default waiter role_id
const [staffRole, setStaffRole] = useState<string>('waiter'); // display only
const [roles, setRoles] = useState<any[]>([]); // RoleRead[] from backend

View File

@ -127,7 +127,16 @@ export const SuppliersView: React.FC = () => {
return;
}
// Backend schema: { ingredient_id, quantity, unit_price }
setPoItems(prev => [...prev, { ingredient_id: Number(addIngId), quantity: addIngQty, unit_price: addIngPrice }]);
setPoItems(prev => [
...prev,
{
ingredient_id: Number(addIngId),
quantity: addIngQty,
unit_price: addIngPrice,
qty: addIngQty,
price: addIngPrice,
},
]);
setAddIngId('');
};

View File

@ -1,102 +1,2 @@
import React from 'react';
import { Chip, Box, Typography } from '@mui/material';
import type { OrderStatus, OrderType } from '../../types';
interface StatusBadgeProps {
status?: OrderStatus;
type?: OrderType;
dietary?: 'veg' | 'non-veg' | 'jain' | 'vegan';
size?: 'small' | 'medium';
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type, dietary, size = 'small' }) => {
if (dietary) {
const config = {
veg: { label: 'VEG', color: '#2e7d32', bg: '#e8f5e9', border: '#2e7d32' },
'non-veg': { label: 'NON-VEG', color: '#c62828', bg: '#ffebee', border: '#c62828' },
jain: { label: 'JAIN', color: '#ef6c00', bg: '#fff3e0', border: '#ef6c00' },
vegan: { label: 'VEGAN', color: '#1565c0', bg: '#e3f2fd', border: '#1565c0' },
}[dietary];
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
border: `1px solid ${config.border}`,
borderRadius: '4px',
px: 0.6,
py: 0.2,
backgroundColor: config.bg,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: config.color,
}}
/>
<Typography
variant="caption"
sx={{
color: config.color,
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.05em',
}}
>
{config.label}
</Typography>
</Box>
);
}
if (status) {
const statusMap = {
pending: { label: 'PENDING', color: 'error', bg: '#ffdad6' },
preparing: { label: 'PREPARING', color: 'warning', bg: '#ffddba' },
ready: { label: 'READY', color: 'success', bg: '#a3f69c' },
served: { label: 'SERVED', color: 'default', bg: '#eeeeee' },
}[status];
return (
<Chip
label={statusMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
}}
color={statusMap.color as any}
/>
);
}
if (type) {
const typeMap = {
'Dine-In': { label: 'DINE-IN', color: '#ac2d00', bg: '#ffdbd1' },
Takeaway: { label: 'TAKEAWAY', color: '#546067', bg: '#d7e4ec' },
Delivery: { label: 'DELIVERY', color: '#845000', bg: '#ffddba' },
}[type];
return (
<Chip
label={typeMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
backgroundColor: typeMap.bg,
color: typeMap.color,
}}
/>
);
}
return null;
};
/** Re-export shared badges from @restroai/ui. */
export { StatusBadge, DietaryBadge } from '@restroai/ui';

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Container,
@ -9,81 +9,239 @@ import {
Divider,
Alert,
Grid,
CircularProgress,
} from '@mui/material';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorOutlineIcon from '@mui/icons-material/WarningAmber';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import PersonIcon from '@mui/icons-material/Person';
import CreditCardIcon from '@mui/icons-material/CreditCard';
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
import { api } from '../../services/api';
import { api, getFrontendMenuItem } from '../../services/api';
interface BillPaymentViewProps {
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
type PayOutcome = 'idle' | 'success' | 'failure';
declare global {
interface Window {
Razorpay?: new (options: Record<string, unknown>) => { open: () => void };
}
}
async function loadRazorpayScript(): Promise<void> {
if (window.Razorpay) return;
await new Promise<void>((resolve, reject) => {
const existing = document.querySelector('script[data-razorpay="1"]');
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('Razorpay script failed')));
return;
}
const script = document.createElement('script');
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
script.async = true;
script.dataset.razorpay = '1';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Razorpay checkout'));
document.body.appendChild(script);
});
}
export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
const [splitCount, setSplitCount] = useState<number>(1);
const [tipPercentage, setTipPercentage] = useState<number>(10);
const [paymentDone, setPaymentDone] = useState<boolean>(false);
const [outcome, setOutcome] = useState<PayOutcome>('idle');
const [paymentMethod, setPaymentMethod] = useState<'upi' | 'card' | 'cash' | null>(null);
const [billItems, setBillItems] = useState<any[]>([]);
const [billItems, setBillItems] = useState<
{ name: string; qty: number; price: number }[]
>([]);
const [ticketNum, setTicketNum] = useState<string>('---');
const [tableNum, setTableNum] = useState<string>('12');
const [billId, setBillId] = useState<string>('');
const [billId, setBillId] = useState<number | null>(null);
const [billTotal, setBillTotal] = useState<number>(0);
const [billSubtotal, setBillSubtotal] = useState<number>(0);
const [cgstAmount, setCgstAmount] = useState<number>(0);
const [sgstAmount, setSgstAmount] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cashHint, setCashHint] = useState(false);
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
const sessionId = sessionStorage.getItem('customer_session_id') || '';
const loadBill = async () => {
const markSuccess = useCallback((method: 'upi' | 'card' | 'cash') => {
setPaymentMethod(method);
setOutcome('success');
const url = new URL(window.location.href);
url.searchParams.set('pay', 'success');
if (billId) url.searchParams.set('bill_id', String(billId));
window.history.replaceState({}, '', url.toString());
}, [billId]);
const markFailure = useCallback((message?: string) => {
setOutcome('failure');
if (message) setError(message);
const url = new URL(window.location.href);
url.searchParams.set('pay', 'failure');
if (billId) url.searchParams.set('bill_id', String(billId));
window.history.replaceState({}, '', url.toString());
}, [billId]);
const loadBill = useCallback(async () => {
setLoading(true);
setError(null);
try {
const payParam = new URLSearchParams(window.location.search).get('pay');
if (payParam === 'success') setOutcome('success');
if (payParam === 'failure') setOutcome('failure');
if (!sessionId) {
setError('No active table session. Scan the QR code to start.');
return;
}
const order = await api.getOrderForSession(sessionId);
if (order) {
setTicketNum(order.ticketNumber || '105');
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12');
if (!order) {
setError('No order found for this table yet.');
return;
}
setBillItems(order.items.map((it: any) => ({
name: it.name,
qty: it.quantity,
price: it.price || 150
})));
setTicketNum(String(order.id ?? '---'));
setTableNum(sessionStorage.getItem('customer_table_id') || '12');
const bill = await api.generateBill(order.id);
if (bill) {
setBillId(bill.id);
}
}
} catch (e) {
console.error('Failed to load bill', e);
}
const items = (order.items || []).map((it: any) => {
const menu = getFrontendMenuItem?.(it.menu_item_id);
return {
name: it.name || menu?.name || `Dish ${it.menu_item_id}`,
qty: Number(it.quantity) || 1,
price: Number(it.unit_price ?? it.price ?? menu?.price ?? 0),
};
});
setBillItems(items);
const bill = await api.sessionGenerateBill(order.id);
if (bill) {
setBillId(Number(bill.id));
setBillSubtotal(Number(bill.subtotal));
setCgstAmount(Number(bill.cgst_amount));
setSgstAmount(Number(bill.sgst_amount));
setBillTotal(Number(bill.total_amount));
if (bill.status === 'paid') {
setOutcome('success');
setPaymentMethod('upi');
}
}
} catch (e: any) {
console.error('Failed to load bill', e);
setError(e?.message || 'Failed to load bill');
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
loadBill();
}, []);
}, [loadBill]);
const subtotal = billItems.reduce((acc, i) => acc + i.price * i.qty, 0);
const cgst = subtotal * 0.025;
const sgst = subtotal * 0.025;
const serviceCharge = subtotal * 0.05;
const tip = subtotal * (tipPercentage / 100);
const totalBill = subtotal + cgst + sgst + serviceCharge + tip;
const perPersonPrice = totalBill / splitCount;
const tip = billSubtotal * (tipPercentage / 100);
const displayTotal = billTotal + tip;
const perPersonPrice = displayTotal / splitCount;
const handlePay = async (method: 'upi' | 'card' | 'cash') => {
try {
if (billId) {
await api.recordPayment(billId, method, totalBill);
const confirmMockCheckout = async (
checkout: any,
method: 'upi' | 'card'
) => {
await api.sessionConfirmRazorpay(checkout.bill_id, {
razorpay_order_id: checkout.razorpay_order_id,
razorpay_payment_id: `pay_mock_${Date.now()}`,
razorpay_signature: 'mock',
amount: Number(checkout.amount),
method,
});
markSuccess(method);
};
const openLiveCheckout = async (checkout: any, method: 'upi' | 'card') => {
await loadRazorpayScript();
if (!window.Razorpay) {
throw new Error('Razorpay checkout unavailable');
}
setPaymentMethod(method);
setPaymentDone(true);
} catch (e) {
alert('Failed to record payment: ' + e);
const amountPaise = Math.round(Number(checkout.amount) * 100);
const rzp = new window.Razorpay({
key: checkout.key_id,
amount: amountPaise,
currency: checkout.currency || 'INR',
name: 'RestroAI',
description: `Bill #${checkout.bill_id}`,
order_id: checkout.razorpay_order_id,
handler: async (response: {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
}) => {
try {
await api.sessionConfirmRazorpay(checkout.bill_id, {
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
amount: Number(checkout.amount),
method,
});
markSuccess(method);
} catch (err: any) {
markFailure(err?.message || 'Payment confirmation failed');
}
},
modal: {
ondismiss: () => {
markFailure('Payment cancelled');
},
},
theme: { color: '#ac2d00' },
});
rzp.open();
};
const handleGatewayPay = async (method: 'upi' | 'card') => {
if (!billId) {
setError('Bill not ready yet');
return;
}
setPaying(true);
setError(null);
setCashHint(false);
try {
const checkout = await api.sessionCheckoutRazorpay(billId);
if (checkout.mock) {
await confirmMockCheckout(checkout, method);
} else {
await openLiveCheckout(checkout, method);
}
} catch (e: any) {
markFailure(e?.message || 'Checkout failed');
} finally {
setPaying(false);
}
};
const handleCash = () => {
setCashHint(true);
setPaymentMethod('cash');
};
if (loading) {
return (
<Box sx={{ minHeight: '60vh', display: 'grid', placeItems: 'center' }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box
sx={{
position: 'sticky',
@ -104,19 +262,26 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Bill & Payment
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {tableNum} · Ticket #{ticketNum}
Table {tableNum} · Order #{ticketNum}
{billId ? ` · Bill #${billId}` : ''}
</Typography>
</Box>
<Chip
icon={<ReceiptLongIcon fontSize="small" />}
label={paymentDone ? 'PAID ✓' : 'UNPAID'}
color={paymentDone ? 'success' : 'error'}
label={outcome === 'success' ? 'PAID ✓' : outcome === 'failure' ? 'FAILED' : 'UNPAID'}
color={outcome === 'success' ? 'success' : outcome === 'failure' ? 'warning' : 'error'}
sx={{ fontWeight: 800 }}
/>
</Box>
<Container maxWidth="sm" sx={{ pt: 3 }}>
{paymentDone ? (
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: '12px' }}>
{error}
</Alert>
)}
{outcome === 'success' ? (
<Paper
elevation={0}
sx={{
@ -132,10 +297,13 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Payment Successful!
</Typography>
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}>
{totalBill.toFixed(0)} paid via {paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}
{billTotal.toFixed(0)} paid
{paymentMethod
? ` via ${paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}`
: ''}
</Typography>
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}>
Thank you for dining with RestroAI! Your receipt has been sent.
Thank you for dining with RestroAI!
</Typography>
<Button
variant="contained"
@ -147,16 +315,61 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Back to Home
</Button>
</Paper>
) : outcome === 'failure' ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
borderRadius: '24px',
border: '2px solid #ffccbc',
bgcolor: '#fff8f6',
mb: 2,
}}
>
<ErrorOutlineIcon sx={{ fontSize: 64, color: '#ac2d00', mb: 2 }} />
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00', mb: 1 }}>
Payment not completed
</Typography>
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 3 }}>
You can try again or ask staff for help.
</Typography>
<Button
variant="contained"
onClick={() => {
setOutcome('idle');
setError(null);
const url = new URL(window.location.href);
url.searchParams.delete('pay');
window.history.replaceState({}, '', url.toString());
}}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800, bgcolor: '#ac2d00' }}
>
Try again
</Button>
</Paper>
) : (
<>
{/* Itemized Bill */}
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Paper
elevation={0}
sx={{
p: 2.5,
mb: 2,
borderRadius: '16px',
border: '1px solid #f0ebe7',
bgcolor: '#ffffff',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
🧾 Bill Details
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontFamily: 'monospace', fontWeight: 700 }}>
GSTIN: 29XXXXXX0001Z5
Bill Details
</Typography>
</Box>
@ -174,32 +387,48 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
<Divider sx={{ my: 1.5 }} />
{[
{ label: 'Subtotal', value: `${subtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.toFixed(0)}` },
{ label: 'Service Charge (5%)', value: `${serviceCharge.toFixed(0)}` },
{ label: `Staff Tip (${tipPercentage}%)`, value: `${tip.toFixed(0)}` },
{ label: 'Subtotal', value: `${billSubtotal.toFixed(0)}` },
{ label: 'CGST', value: `${cgstAmount.toFixed(0)}` },
{ label: 'SGST', value: `${sgstAmount.toFixed(0)}` },
{ label: `Suggested tip (${tipPercentage}%)`, value: `${tip.toFixed(0)}` },
].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
{row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.value}
</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Total Amount</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
Pay now
</Typography>
<Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{totalBill.toFixed(0)}
{billTotal.toFixed(0)}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
Gateway charges bill total (tip is optional / cash).
</Typography>
</Paper>
{/* Split Bill */}
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
borderRadius: '16px',
border: '1px solid #f0ebe7',
bgcolor: '#ffffff',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
👥 Split the Bill
Split the Bill
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
{[1, 2, 3, 4].map((count) => (
@ -217,15 +446,23 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
</Box>
{splitCount > 1 && (
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
Each person pays: {perPersonPrice.toFixed(0)}
Each person {perPersonPrice.toFixed(0)} (incl. suggested tip)
</Alert>
)}
</Paper>
{/* Tip Selector */}
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Paper
elevation={0}
sx={{
p: 2,
mb: 3,
borderRadius: '16px',
border: '1px solid #f0ebe7',
bgcolor: '#ffffff',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
Add Tip for Staff
Suggested tip
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{[0, 10, 15, 20].map((pct) => (
@ -242,19 +479,33 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
</Box>
</Paper>
{/* Payment Options */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#8f7068', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{cashHint && (
<Alert severity="info" sx={{ mb: 2, borderRadius: '12px', fontWeight: 700 }}>
Please pay cash to your waiter. They will mark the bill paid at the counter.
</Alert>
)}
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: '#8f7068',
mb: 1.5,
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Choose Payment Method
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* UPI Button */}
<Button
fullWidth
variant="contained"
size="large"
onClick={() => handlePay('upi')}
startIcon={<QrCode2Icon />}
disabled={paying || !billId}
onClick={() => handleGatewayPay('upi')}
startIcon={paying ? <CircularProgress size={18} color="inherit" /> : <QrCode2Icon />}
sx={{
py: 1.8,
borderRadius: '14px',
@ -264,7 +515,7 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
'&:hover': { bgcolor: '#38006b' },
}}
>
Pay {totalBill.toFixed(0)} via UPI / GPay / PhonePe
Pay {billTotal.toFixed(0)} via UPI / GPay / PhonePe
</Button>
<Grid container spacing={1.5}>
@ -273,9 +524,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
fullWidth
variant="outlined"
size="large"
onClick={() => handlePay('card')}
disabled={paying || !billId}
onClick={() => handleGatewayPay('card')}
startIcon={<CreditCardIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
sx={{
py: 1.5,
borderRadius: '12px',
fontWeight: 800,
color: '#ac2d00',
borderColor: '#ac2d00',
}}
>
Debit / Credit Card
</Button>
@ -285,9 +543,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
fullWidth
variant="outlined"
size="large"
onClick={() => handlePay('cash')}
disabled={paying}
onClick={handleCash}
startIcon={<CurrencyRupeeIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#2e7d32', borderColor: '#2e7d32' }}
sx={{
py: 1.5,
borderRadius: '12px',
fontWeight: 800,
color: '#2e7d32',
borderColor: '#2e7d32',
}}
>
Pay by Cash
</Button>

View File

@ -0,0 +1,234 @@
import React from 'react';
import {
Box,
Typography,
Drawer,
Button,
IconButton,
Divider,
CardMedia,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
import DeleteIcon from '@mui/icons-material/Delete';
import ShoppingCartCheckoutIcon from '@mui/icons-material/ShoppingCartCheckout';
import CloseIcon from '@mui/icons-material/Close';
import type { MenuItem } from '../../data/menuData';
export type CartLine = {
item: MenuItem;
qty: number;
notes?: string;
};
interface CartPeekDrawerProps {
open: boolean;
lines: CartLine[];
subtotal: number;
onClose: () => void;
onUpdateCart: (itemId: string, quantity: number) => void;
onCheckout: () => void;
onContinueShopping: () => void;
}
export const CartPeekDrawer: React.FC<CartPeekDrawerProps> = ({
open,
lines,
subtotal,
onClose,
onUpdateCart,
onCheckout,
onContinueShopping,
}) => {
const totalUnits = lines.reduce((sum, l) => sum + l.qty, 0);
return (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
sx: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '78vh',
bgcolor: '#fff',
},
}}
>
<Box sx={{ px: 2, pt: 1.5, pb: 2, display: 'flex', flexDirection: 'column', maxHeight: '78vh' }}>
<Box
sx={{
width: 40,
height: 4,
borderRadius: 2,
bgcolor: '#e4ddd8',
alignSelf: 'center',
mb: 1.5,
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography sx={{ fontWeight: 900, color: '#1a1c1c', fontSize: '1.1rem' }}>
Your cart
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{totalUnits === 0
? 'No items yet'
: `${totalUnits} item${totalUnits === 1 ? '' : 's'} · ₹${subtotal.toFixed(0)}`}
</Typography>
</Box>
<IconButton onClick={onClose} size="small" aria-label="Close cart">
<CloseIcon />
</IconButton>
</Box>
{lines.length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>Cart is empty</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 2.5 }}>
Add dishes from the menu, then check them here anytime.
</Typography>
<Button
variant="contained"
onClick={onContinueShopping}
sx={{ borderRadius: '9999px', fontWeight: 800, bgcolor: '#ac2d00', px: 3 }}
>
Browse menu
</Button>
</Box>
) : (
<>
<Box sx={{ overflowY: 'auto', flex: 1, minHeight: 0, pr: 0.5, mb: 2 }}>
{lines.map(({ item, qty, notes }) => (
<Box
key={item.id}
sx={{
display: 'flex',
gap: 1.25,
alignItems: 'center',
py: 1.25,
borderBottom: '1px solid #f0ebe7',
}}
>
<CardMedia
component="img"
image={item.image}
alt={item.name}
sx={{ width: 56, height: 56, borderRadius: 2, objectFit: 'cover', flexShrink: 0 }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontWeight: 800,
fontSize: '0.9rem',
lineHeight: 1.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name}
</Typography>
<Typography variant="caption" sx={{ color: '#ac2d00', fontWeight: 800 }}>
{item.price} · {(item.price * qty).toFixed(0)}
</Typography>
{notes ? (
<Typography
variant="caption"
sx={{ display: 'block', color: '#8f7068', mt: 0.25, lineHeight: 1.3 }}
>
{notes}
</Typography>
) : null}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: '#ac2d00',
borderRadius: '9999px',
}}
>
<IconButton
size="small"
onClick={() => onUpdateCart(String(item.id), qty - 1)}
sx={{ color: '#fff', p: 0.5 }}
aria-label={`Decrease ${item.name}`}
>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography sx={{ px: 1, color: '#fff', fontWeight: 900, fontSize: '0.85rem', minWidth: 18, textAlign: 'center' }}>
{qty}
</Typography>
<IconButton
size="small"
onClick={() => onUpdateCart(String(item.id), qty + 1)}
sx={{ color: '#fff', p: 0.5 }}
aria-label={`Increase ${item.name}`}
>
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<IconButton
size="small"
color="error"
onClick={() => onUpdateCart(String(item.id), 0)}
aria-label={`Remove ${item.name}`}
>
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Box>
))}
</Box>
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5 }}>
<Typography sx={{ fontWeight: 700, color: '#5b4139' }}>Subtotal</Typography>
<Typography sx={{ fontWeight: 900, color: '#ac2d00', fontSize: '1.1rem' }}>
{subtotal.toFixed(0)}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
fullWidth
variant="outlined"
onClick={onContinueShopping}
sx={{
borderRadius: '9999px',
fontWeight: 800,
py: 1.25,
color: '#ac2d00',
borderColor: '#ac2d00',
}}
>
Add more
</Button>
<Button
fullWidth
variant="contained"
endIcon={<ShoppingCartCheckoutIcon />}
onClick={onCheckout}
sx={{
borderRadius: '9999px',
fontWeight: 900,
py: 1.25,
bgcolor: '#ac2d00',
boxShadow: '0 6px 18px rgba(172,45,0,0.35)',
'&:hover': { bgcolor: '#872100' },
}}
>
Checkout
</Button>
</Box>
</>
)}
</Box>
</Drawer>
);
};

View File

@ -11,6 +11,9 @@ import {
Chip,
Card,
CardMedia,
BottomNavigation,
BottomNavigationAction,
Badge,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AddIcon from '@mui/icons-material/Add';
@ -18,41 +21,65 @@ import RemoveIcon from '@mui/icons-material/Remove';
import DeleteIcon from '@mui/icons-material/Delete';
import SendIcon from '@mui/icons-material/Send';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import HomeIcon from '@mui/icons-material/Home';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
import { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge';
interface CartReviewViewProps {
cart: { [itemId: string]: number };
cartNotes?: { [itemId: string]: string };
menuItems?: MenuItem[];
onUpdateCart: (itemId: string, quantity: number) => void;
onPlaceOrder: (orderNotes: string) => void;
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
const tableLabel = () =>
sessionStorage.getItem('customer_table_number') ||
sessionStorage.getItem('customer_table_id') ||
'—';
export const CartReviewView: React.FC<CartReviewViewProps> = ({
cart,
cartNotes = {},
menuItems = MENU_ITEMS,
onUpdateCart,
onPlaceOrder,
onNavigate,
}) => {
const [orderNotes, setOrderNotes] = useState<string>('');
const [placing, setPlacing] = useState(false);
const cartEntries = Object.entries(cart)
.map(([id, qty]) => {
const item = MENU_ITEMS.find((m) => m.id === id);
return item ? { item, qty } : null;
const item = menuItems.find((m) => String(m.id) === id);
return item ? { item, qty, notes: cartNotes[id] } : null;
})
.filter(Boolean) as { item: MenuItem; qty: number }[];
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[];
const totalUnits = cartEntries.reduce((sum, e) => sum + e.qty, 0);
const subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
const cgst = subtotal * 0.025; // CGST 2.5%
const sgst = subtotal * 0.025; // SGST 2.5%
const serviceCharge = subtotal * 0.05; // 5% service charge
const cgst = subtotal * 0.025;
const sgst = subtotal * 0.025;
const serviceCharge = subtotal * 0.05;
const grandTotal = subtotal + cgst + sgst + serviceCharge;
const handlePlace = async () => {
if (placing || cartEntries.length === 0) return;
setPlacing(true);
try {
await Promise.resolve(onPlaceOrder(orderNotes));
} finally {
setPlacing(false);
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: cartEntries.length ? 22 : 12 }}>
<Box
sx={{
position: 'sticky',
@ -68,22 +95,22 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={() => onNavigate('menu')} size="small">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<IconButton onClick={() => onNavigate('menu')} size="small" aria-label="Back to menu">
<ArrowBackIcon sx={{ color: '#ac2d00' }} />
</IconButton>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Your Cart
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.15 }}>
Review order
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'}
Table {tableLabel()} · Check items before placing
</Typography>
</Box>
</Box>
<Chip
label={`${cartEntries.length} item${cartEntries.length !== 1 ? 's' : ''}`}
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00' }}
label={`${totalUnits} item${totalUnits === 1 ? '' : 's'}`}
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00', flexShrink: 0 }}
/>
</Box>
@ -93,22 +120,29 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
elevation={0}
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }}
>
<Typography sx={{ fontSize: '2.5rem', mb: 1 }}>🛒</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
Your cart is empty
</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}>
Go back and add some delicious dishes!
Browse the menu and add dishes. You can check your cart anytime while ordering.
</Typography>
<Button variant="contained" color="primary" onClick={() => onNavigate('menu')}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}>
Browse Menu
<Button
variant="contained"
color="primary"
onClick={() => onNavigate('menu')}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
>
Browse menu
</Button>
</Paper>
) : (
<>
{/* Cart Items */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, px: 0.5 }}>
Items in your cart
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
{cartEntries.map(({ item, qty }) => (
{cartEntries.map(({ item, qty, notes }) => (
<Card
key={item.id}
elevation={0}
@ -129,43 +163,84 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
alt={item.name}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
{item.name}
</Typography>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', my: 0.5 }}>
{(item.price * qty).toFixed(0)}
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{item.price} each
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{notes ? (
<Typography
variant="caption"
sx={{
display: 'block',
color: '#845000',
bgcolor: '#fff8ee',
borderRadius: 1,
px: 0.75,
py: 0.35,
mt: 0.5,
fontWeight: 600,
}}
>
{notes}
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#f8f5f2', borderRadius: '9999px' }}>
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty - 1)}>
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty - 1)}>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="body2" sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00' }}>
<Typography
variant="body2"
sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00', minWidth: 20, textAlign: 'center' }}
>
{qty}
</Typography>
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty + 1)}>
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty + 1)}>
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<IconButton size="small" color="error" onClick={() => onUpdateCart(item.id, 0)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ fontWeight: 900, color: '#ac2d00' }}>{(item.price * qty).toFixed(0)}</Typography>
<IconButton size="small" color="error" onClick={() => onUpdateCart(String(item.id), 0)} aria-label="Remove">
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Box>
</Box>
</Card>
))}
</Box>
{/* Instructions */}
<Button
fullWidth
variant="outlined"
onClick={() => onNavigate('menu')}
startIcon={<AddIcon />}
sx={{
mb: 2,
borderRadius: '12px',
fontWeight: 800,
py: 1.25,
color: '#ac2d00',
borderColor: '#e4ddd8',
borderStyle: 'dashed',
'&:hover': { borderColor: '#ac2d00', bgcolor: '#fff8f5' },
}}
>
Add more items
</Button>
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
📝 Special Instructions
Special instructions (optional)
</Typography>
<TextField
placeholder="E.g. Extra spicy, no onion, serve starters first..."
placeholder="E.g. Extra spicy, no onion, serve starters first"
multiline
rows={2}
fullWidth
@ -176,60 +251,125 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
/>
</Paper>
{/* Bill Summary */}
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Bill Summary
Bill summary
</Typography>
</Box>
{[
{ label: 'Items Total', value: `${subtotal.toFixed(0)}` },
{ label: 'Items total', value: `${subtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.toFixed(0)}` },
{ label: 'Service Charge (5%)', value: `${serviceCharge.toFixed(0)}` },
{ label: 'Service charge (5%)', value: `${serviceCharge.toFixed(0)}` },
].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6 }}>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.55 }}>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
{row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.value}
</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Grand Total</Typography>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>{grandTotal.toFixed(0)}</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
To pay
</Typography>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{grandTotal.toFixed(0)}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}>
*Inclusive of all taxes as per GST regulations
Inclusive of taxes · Review carefully before placing
</Typography>
</Paper>
</>
)}
</Container>
{/* Place Order CTA */}
{cartEntries.length > 0 && (
<Box
sx={{
position: 'fixed',
left: 0,
right: 0,
bottom: 64,
zIndex: 120,
px: 2,
py: 1.25,
bgcolor: 'rgba(248,245,242,0.92)',
backdropFilter: 'blur(8px)',
borderTop: '1px solid #f0ebe7',
}}
>
<Container maxWidth="sm" disableGutters>
<Button
fullWidth
variant="contained"
size="large"
onClick={() => onPlaceOrder(orderNotes)}
disabled={placing}
onClick={() => void handlePlace()}
endIcon={<SendIcon />}
sx={{
py: 2,
py: 1.6,
borderRadius: '9999px',
fontSize: '1.05rem',
fontSize: '1rem',
fontWeight: 900,
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
}}
>
Place Order {grandTotal.toFixed(0)}
{placing ? 'Placing order…' : `Place order · ₹${grandTotal.toFixed(0)}`}
</Button>
</>
)}
</Container>
</Box>
)}
<Paper
elevation={8}
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
borderTop: '1px solid #f0ebe7',
}}
>
<BottomNavigation
value={2}
onChange={(_, v) => {
if (v === 0) onNavigate('landing');
if (v === 1) onNavigate('menu');
if (v === 3) onNavigate('status');
}}
sx={{ bgcolor: '#ffffff', height: 64 }}
>
<BottomNavigationAction label="Home" icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
<BottomNavigationAction label="Menu" icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
<BottomNavigationAction
label="Cart"
icon={
<Badge badgeContent={totalUnits} color="error">
<ShoppingCartIcon />
</Badge>
}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="Status"
icon={<TrackChangesIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
</BottomNavigation>
</Paper>
</Box>
);
};

View File

@ -44,8 +44,8 @@ export const CustomizationModal: React.FC<CustomizationModalProps> = ({
const handleAdd = () => {
const formattedNotes = [
`Spice: ${spiceLevel}`,
extraCheese ? 'Extra Cheese (+ $2.50)' : '',
extraTruffle ? 'Extra Truffle Oil (+ $3.00)' : '',
extraCheese ? 'Extra Cheese (+₹40)' : '',
extraTruffle ? 'Extra Truffle Oil (+₹60)' : '',
specialNotes ? `Note: ${specialNotes}` : '',
]
.filter(Boolean)

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
@ -13,6 +13,8 @@ import {
BottomNavigation,
BottomNavigationAction,
Button,
Snackbar,
Alert,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
@ -27,9 +29,13 @@ import StarIcon from '@mui/icons-material/Star';
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
import type { MenuItem, CategoryType } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge';
import { useLocale } from '../../i18n/LocaleContext';
import { CartPeekDrawer } from './CartPeekDrawer';
interface MenuBrowseViewProps {
cart: { [itemId: string]: number };
cartNotes?: { [itemId: string]: string };
menuItems?: MenuItem[];
onUpdateCart: (itemId: string, quantity: number) => void;
onOpenCustomization: (item: MenuItem) => void;
onOpenVoice: () => void;
@ -50,26 +56,49 @@ const SPICE_ICONS: Record<string, string> = {
'extra-hot': '🔥',
};
const tableLabel = () =>
sessionStorage.getItem('customer_table_number') ||
sessionStorage.getItem('customer_table_id') ||
'—';
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
cart,
cartNotes = {},
menuItems = MENU_ITEMS,
onUpdateCart,
onOpenCustomization,
onOpenVoice,
onNavigate,
}) => {
const { t } = useLocale();
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
const [searchQuery, setSearchQuery] = useState('');
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
const [bottomNav, setBottomNav] = useState(1); // 1 = Menu
const [bottomNav, setBottomNav] = useState(1);
const [cartPeekOpen, setCartPeekOpen] = useState(false);
const [emptyHint, setEmptyHint] = useState(false);
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
const item = MENU_ITEMS.find((m) => m.id === id);
const item = menuItems.find((m) => String(m.id) === id);
return sum + (item ? item.price * qty : 0);
}, 0);
const cartLines = useMemo(
() =>
Object.entries(cart)
.map(([id, qty]) => {
const item = menuItems.find((m) => String(m.id) === id);
if (!item) return null;
return { item, qty, notes: cartNotes[id] };
})
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[],
[cart, cartNotes, menuItems]
);
const filteredItems = useMemo(() => {
return MENU_ITEMS.filter((item) => {
return menuItems.filter((item) => {
if (item.isAvailable === false) return false;
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
const matchSearch =
!searchQuery ||
@ -79,20 +108,31 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
return matchCategory && matchSearch && matchDietary;
});
}, [activeCategory, searchQuery, dietaryFilter]);
}, [menuItems, activeCategory, searchQuery, dietaryFilter]);
const openCartCheck = () => {
setCartPeekOpen(true);
};
const handleCartTap = () => {
openCartCheck();
if (totalCartItems === 0) setEmptyHint(true);
};
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
setBottomNav(newValue);
if (newValue === 0) onNavigate('landing');
if (newValue === 1) setBottomNav(1);
if (newValue === 2) {
if (totalCartItems > 0) onNavigate('cart');
openCartCheck();
if (totalCartItems === 0) setEmptyHint(true);
setBottomNav(1);
}
if (newValue === 3) onNavigate('status');
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
{/* Sticky Header */}
<Box
sx={{
position: 'sticky',
@ -103,30 +143,27 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
}}
>
{/* Top bar */}
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
RestroAI Menu
Menu
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'} · Dine-In
Table {tableLabel()} · Tap dishes to add · Check cart anytime
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
<IconButton
onClick={onOpenVoice}
sx={{
bgcolor: '#ffdbd1',
color: '#ac2d00',
'&:hover': { bgcolor: '#ffcab8' },
}}
aria-label="Voice order"
sx={{ bgcolor: '#ffdbd1', color: '#ac2d00', '&:hover': { bgcolor: '#ffcab8' } }}
>
<MicIcon />
</IconButton>
<Badge badgeContent={totalCartItems} color="error">
<IconButton
onClick={() => totalCartItems > 0 && onNavigate('cart')}
onClick={handleCartTap}
aria-label="View cart"
sx={{
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
color: totalCartItems > 0 ? '#fff' : '#8f7068',
@ -139,12 +176,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
</Box>
</Box>
{/* Search Bar */}
<Box sx={{ px: 2, pb: 1 }}>
<TextField
fullWidth
size="small"
placeholder="Search dishes, e.g. biryani, paneer..."
placeholder="Search dishes"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
slotProps={{
@ -166,21 +202,26 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
/>
</Box>
{/* Category Tabs - Horizontal Scroll */}
<Box
sx={{
display: 'flex',
gap: 0.8,
px: 2,
pb: 1.5,
pb: 1,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{CATEGORIES.map((cat) => {
const catEmoji: Record<string, string> = {
All: '🍽️', Starters: '🥗', Biryani: '🍚', Mains: '🍛',
Breads: '🫓', Drinks: '🥤', Desserts: '🍮', Specials: '⭐',
All: '🍽️',
Starters: '🥗',
Biryani: '🍚',
Mains: '🍛',
Breads: '🫓',
Drinks: '🥤',
Desserts: '🍮',
Specials: '⭐',
};
const isActive = activeCategory === cat;
return (
@ -195,15 +236,13 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
color: isActive ? '#fff' : '#5b4139',
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
transition: 'all 0.15s ease',
}}
/>
);
})}
</Box>
{/* Dietary Filters */}
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.5, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.25, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
{DIETARY_FILTERS.map((f) => (
<Chip
key={f.label}
@ -218,19 +257,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
</Box>
</Box>
{/* AI Banner */}
<Box sx={{ px: 2, pt: 2 }}>
<Box sx={{ px: 2, pt: 1.5 }}>
<Paper
elevation={0}
sx={{
p: 2,
p: 1.75,
borderRadius: '16px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
color: '#fff',
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
mb: 1.5,
cursor: 'pointer',
}}
onClick={onOpenVoice}
@ -249,163 +287,140 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
>
<AutoAwesomeIcon />
</Box>
<Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
🍛 Today's AI Pick: Hyderabadi Chicken Biryani
Order with AI voice
</Typography>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
Tap to voice order or browse below · 380
<Typography variant="caption" sx={{ opacity: 0.9 }}>
Say what you want we add it to your cart
</Typography>
</Box>
</Paper>
</Box>
{/* Menu Cards */}
<Box sx={{ flex: 1, px: 2, pb: 20 }}>
<Box sx={{ flex: 1, px: 2, pb: totalCartItems > 0 ? 22 : 14 }}>
{activeCategory !== 'All' && (
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{activeCategory} ({filteredItems.length} items)
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{activeCategory} ({filteredItems.length})
</Typography>
)}
{filteredItems.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>No dishes found</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a' }}>Try a different search or filter</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>
No dishes found
</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 2 }}>
Try a different search or filter
</Typography>
<Button
variant="outlined"
onClick={() => {
setSearchQuery('');
setDietaryFilter(null);
setActiveCategory('All');
}}
sx={{ borderRadius: '9999px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
>
Clear filters
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{filteredItems.map((item) => {
const qty = cart[item.id] || 0;
const qty = cart[String(item.id)] || 0;
return (
<Card
key={item.id}
elevation={0}
sx={{
borderRadius: '20px',
border: '1px solid #f0ebe7',
borderRadius: '16px',
border: qty > 0 ? '1.5px solid #ac2d00' : '1px solid #f0ebe7',
overflow: 'hidden',
bgcolor: '#ffffff',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
transition: 'box-shadow 0.2s ease',
'&:hover': { boxShadow: '0 6px 20px rgba(0,0,0,0.08)' },
display: 'flex',
boxShadow: '0 2px 10px rgba(0,0,0,0.04)',
}}
>
{/* Food Image */}
<Box sx={{ position: 'relative' }}>
<Box sx={{ position: 'relative', width: 112, flexShrink: 0 }}>
<CardMedia
component="img"
height="180"
image={item.image}
alt={item.name}
sx={{ objectFit: 'cover' }}
sx={{ width: 112, height: '100%', minHeight: 112, objectFit: 'cover' }}
/>
{/* Badges on image */}
<Box sx={{ position: 'absolute', top: 10, left: 10, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.isChefSpecial && (
{(item.isChefSpecial || item.isBestseller) && (
<Chip
icon={<AutoAwesomeIcon sx={{ fontSize: '14px !important' }} />}
label="Chef's Special"
size="small"
sx={{
bgcolor: 'rgba(172,45,0,0.9)',
color: '#fff',
fontWeight: 800,
fontSize: '0.65rem',
backdropFilter: 'blur(4px)',
}}
/>
)}
{item.isBestseller && (
<Chip
icon={<StarIcon sx={{ fontSize: '14px !important', color: '#FFD700' }} />}
label="Bestseller"
size="small"
sx={{
bgcolor: 'rgba(0,0,0,0.7)',
color: '#FFD700',
fontWeight: 800,
fontSize: '0.65rem',
backdropFilter: 'blur(4px)',
}}
/>
)}
</Box>
{item.spiceLevel && (
<Chip
label={`${SPICE_ICONS[item.spiceLevel]} ${item.spiceLevel}`}
icon={
item.isChefSpecial ? (
<AutoAwesomeIcon sx={{ fontSize: '12px !important' }} />
) : (
<StarIcon sx={{ fontSize: '12px !important', color: '#FFD700' }} />
)
}
label={item.isChefSpecial ? 'Special' : 'Hit'}
size="small"
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'rgba(255,255,255,0.9)',
fontWeight: 700,
fontSize: '0.65rem',
textTransform: 'capitalize',
top: 6,
left: 6,
height: 22,
fontSize: '0.62rem',
fontWeight: 800,
bgcolor: 'rgba(0,0,0,0.7)',
color: '#fff',
}}
/>
)}
</Box>
{/* Card Content */}
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Box sx={{ flex: 1, mr: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
<Box sx={{ p: 1.5, flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, mb: 0.35 }}>
<Typography sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.25, fontSize: '0.95rem' }}>
{item.name}
</Typography>
{item.nameHindi && (
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{item.nameHindi}
</Typography>
)}
</Box>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 1.5, lineHeight: 1.5, fontSize: '0.8rem' }}>
<Typography
variant="body2"
sx={{
color: '#6b5c57',
mb: 1,
fontSize: '0.75rem',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{item.description}
</Typography>
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.5 }}>
{item.tags.slice(0, 3).map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{ fontSize: '0.62rem', fontWeight: 700, bgcolor: '#f8f5f2', height: 20 }}
/>
))}
</Box>
)}
{/* Price + Action Row */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{item.price}
</Typography>
<Typography sx={{ fontWeight: 900, color: '#ac2d00', fontSize: '1rem' }}>{item.price}</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
{item.prepTimeMinutes} min
{item.spiceLevel ? `${SPICE_ICONS[item.spiceLevel] || ''} ` : ''}
{item.prepTimeMinutes} min
</Typography>
</Box>
{qty === 0 ? (
<Button
variant="outlined"
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => onOpenCustomization(item)}
sx={{
borderRadius: '9999px',
fontWeight: 800,
color: '#ac2d00',
borderColor: '#ac2d00',
px: 2,
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
bgcolor: '#ac2d00',
px: 1.75,
boxShadow: 'none',
'&:hover': { bgcolor: '#872100', boxShadow: 'none' },
}}
>
Add
@ -422,18 +437,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
>
<IconButton
size="small"
onClick={() => onUpdateCart(item.id, qty - 1)}
sx={{ color: '#fff', p: 0.8 }}
onClick={() => onUpdateCart(String(item.id), qty - 1)}
sx={{ color: '#fff', p: 0.7 }}
>
<RemoveIcon fontSize="small" />
</IconButton>
<Typography sx={{ px: 1.5, color: '#fff', fontWeight: 900, fontFamily: 'monospace' }}>
<Typography sx={{ px: 1.25, color: '#fff', fontWeight: 900, minWidth: 20, textAlign: 'center' }}>
{qty}
</Typography>
<IconButton
size="small"
onClick={() => onUpdateCart(item.id, qty + 1)}
sx={{ color: '#fff', p: 0.8 }}
onClick={() => onUpdateCart(String(item.id), qty + 1)}
sx={{ color: '#fff', p: 0.7 }}
>
<AddIcon fontSize="small" />
</IconButton>
@ -448,53 +463,66 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
)}
</Box>
{/* Floating Cart Pill */}
{totalCartItems > 0 && (
<Box
sx={{
position: 'fixed',
bottom: 72,
left: '50%',
transform: 'translateX(-50%)',
left: 12,
right: 12,
zIndex: 200,
maxWidth: 520,
mx: 'auto',
}}
>
<Paper
elevation={8}
onClick={() => onNavigate('cart')}
elevation={10}
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 1.5,
borderRadius: '9999px',
bgcolor: '#ac2d00',
alignItems: 'stretch',
borderRadius: '16px',
overflow: 'hidden',
bgcolor: '#1a1c1c',
color: '#fff',
cursor: 'pointer',
boxShadow: '0 8px 28px rgba(172,45,0,0.45)',
'&:hover': { bgcolor: '#872100' },
transition: 'all 0.2s ease',
minWidth: 260,
justifyContent: 'space-between',
boxShadow: '0 10px 28px rgba(0,0,0,0.28)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge badgeContent={totalCartItems} color="warning" sx={{ '& .MuiBadge-badge': { fontWeight: 800 } }}>
<ShoppingCartIcon />
</Badge>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{totalCartItems} item{totalCartItems > 1 ? 's' : ''} in cart
<Box
onClick={openCartCheck}
sx={{
flex: 1,
px: 2,
py: 1.5,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minWidth: 0,
}}
>
<Typography variant="caption" sx={{ opacity: 0.8, fontWeight: 700 }}>
View cart · {totalCartItems} item{totalCartItems === 1 ? '' : 's'}
</Typography>
<Typography sx={{ fontWeight: 900, fontSize: '1.05rem' }}>{totalCartValue.toFixed(0)}</Typography>
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>
{totalCartValue.toFixed(0)}
</Typography>
<Button
onClick={() => onNavigate('cart')}
sx={{
px: 2.5,
borderRadius: 0,
bgcolor: '#ac2d00',
color: '#fff',
fontWeight: 900,
whiteSpace: 'nowrap',
'&:hover': { bgcolor: '#872100' },
}}
>
Checkout
</Button>
</Paper>
</Box>
)}
{/* Bottom Navigation */}
<Paper
elevation={8}
sx={{
@ -506,23 +534,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
borderTop: '1px solid #f0ebe7',
}}
>
<BottomNavigation
value={bottomNav}
onChange={handleBottomNav}
sx={{ bgcolor: '#ffffff', height: 64 }}
>
<BottomNavigation value={bottomNav} onChange={handleBottomNav} sx={{ bgcolor: '#ffffff', height: 64 }}>
<BottomNavigationAction label={t('brand')} icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
<BottomNavigationAction label={t('menu')} icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
<BottomNavigationAction
label="Home"
icon={<HomeIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="Menu"
icon={<MenuBookIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="Cart"
label={t('cart')}
icon={
<Badge badgeContent={totalCartItems} color="error">
<ShoppingCartIcon />
@ -531,12 +547,37 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="My Order"
label={t('orderStatus')}
icon={<TrackChangesIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
</BottomNavigation>
</Paper>
<CartPeekDrawer
open={cartPeekOpen}
lines={cartLines}
subtotal={totalCartValue}
onClose={() => setCartPeekOpen(false)}
onUpdateCart={onUpdateCart}
onContinueShopping={() => setCartPeekOpen(false)}
onCheckout={() => {
setCartPeekOpen(false);
onNavigate('cart');
}}
/>
<Snackbar
open={emptyHint}
autoHideDuration={2800}
onClose={() => setEmptyHint(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 140, sm: 140 } }}
>
<Alert severity="info" onClose={() => setEmptyHint(false)} sx={{ fontWeight: 700, borderRadius: '12px' }}>
Cart is empty add a dish, then check it here anytime.
</Alert>
</Snackbar>
</Box>
);
};

View File

@ -22,7 +22,15 @@ import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt';
import { api, RestroWebSocket, subscribeToWsEvents } from '../../services/api';
interface PlacedItem {
name: string;
qty: number;
price: number;
notes?: string;
}
interface OrderStatusViewProps {
placedItems?: PlacedItem[];
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
@ -53,14 +61,34 @@ const ORDER_STEPS = [
},
];
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate }) => {
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({
placedItems = [],
onNavigate,
}) => {
const [activeStep, setActiveStep] = useState<number>(0);
const [ticketNum, setTicketNum] = useState<string>('---');
const [tableNum, setTableNum] = useState<string>('12');
const [tableNum, setTableNum] = useState<string>(
() =>
sessionStorage.getItem('customer_table_number') ||
sessionStorage.getItem('customer_table_id') ||
'—'
);
const [waiterNotified, setWaiterNotified] = useState<boolean>(false);
const [eta, setEta] = useState(15);
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
const summaryItems =
placedItems.length > 0
? placedItems
: (() => {
try {
const raw = sessionStorage.getItem('customer_last_order_items');
return raw ? (JSON.parse(raw) as PlacedItem[]) : [];
} catch {
return [] as PlacedItem[];
}
})();
const summaryTotal = summaryItems.reduce((s, i) => s + i.qty * i.price, 0);
const loadOrder = async () => {
try {
@ -211,33 +239,54 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
</Stepper>
</Paper>
{/* Ordered Items Summary */}
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1.5 }}>
🧾 Your Order Summary
Your order summary
</Typography>
{[
{ name: 'Hyderabadi Chicken Biryani', qty: 1, price: 380 },
{ name: 'Paneer Tikka', qty: 1, price: 280 },
{ name: 'Garlic Naan', qty: 2, price: 80 },
{ name: 'Mango Lassi', qty: 2, price: 120 },
].map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6, borderBottom: idx < 3 ? '1px solid #f5f0ec' : 'none' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{summaryItems.length === 0 ? (
<Typography variant="body2" sx={{ color: '#8f7068' }}>
Order details will appear here after you place an order from the cart.
</Typography>
) : (
<>
{summaryItems.map((item, idx) => (
<Box
key={`${item.name}-${idx}`}
sx={{
display: 'flex',
justifyContent: 'space-between',
py: 0.75,
borderBottom: idx < summaryItems.length - 1 ? '1px solid #f5f0ec' : 'none',
gap: 1,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{item.qty}× {item.name}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{item.notes ? (
<Typography variant="caption" sx={{ color: '#8f7068' }}>
{item.notes}
</Typography>
) : null}
</Box>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', flexShrink: 0 }}>
{item.qty * item.price}
</Typography>
</Box>
))}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, pt: 1, borderTop: '2px solid #f0ebe7' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Estimated Total</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>960</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>
Items total
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{summaryTotal.toFixed(0)}
</Typography>
</Box>
</>
)}
</Paper>
{/* Action Buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Button
fullWidth
@ -254,7 +303,7 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
'&:hover': { borderColor: '#845000', bgcolor: '#fff8ee' },
}}
>
Call Waiter to Table 12
Call waiter to Table {tableNum}
</Button>
<Box sx={{ display: 'flex', gap: 1.5 }}>

View File

@ -8,6 +8,14 @@ import {
Chip,
Grid,
IconButton,
Dialog,
DialogTitle,
DialogContent,
List,
ListItemButton,
ListItemText,
CircularProgress,
Alert,
} from '@mui/material';
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
@ -17,44 +25,83 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import WifiIcon from '@mui/icons-material/Wifi';
import MicIcon from '@mui/icons-material/Mic';
import LockIcon from '@mui/icons-material/Lock';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import BadgeIcon from '@mui/icons-material/Badge';
import { useLocale } from '../../i18n/LocaleContext';
import { uiLangToLocale } from '../../i18n/messages';
export type GuestTableOption = {
id: number;
table_number: string;
capacity: number;
status: string;
};
interface TableLandingViewProps {
onStartOrdering: (language: string) => void;
onStartOrdering: (language: 'english' | 'hindi' | 'hinglish') => void;
onOpenStaffLogin?: () => void;
onOpenVoice?: () => void;
selectedTable: GuestTableOption | null;
tableLocked: boolean;
availableTables: GuestTableOption[];
tablesLoading?: boolean;
tablesError?: string | null;
onSelectTable: (table: GuestTableOption) => void;
}
export const TableLandingView: React.FC<TableLandingViewProps> = ({
onStartOrdering,
onOpenStaffLogin,
onOpenVoice,
selectedTable,
tableLocked,
availableTables,
tablesLoading = false,
tablesError = null,
onSelectTable,
}) => {
const { t, setLocale } = useLocale();
const [selectedLang, setSelectedLang] = useState<'english' | 'hindi' | 'hinglish'>('english');
const [pickerOpen, setPickerOpen] = useState(false);
const languages = [
{
id: 'english',
emoji: '🇬🇧',
name: 'English',
subText: 'English',
icon: <LanguageIcon fontSize="large" />,
},
{
id: 'hindi',
emoji: '🇮🇳',
name: 'हिन्दी',
subText: 'Hindi',
icon: <Typography variant="h5" sx={{ fontWeight: 900, lineHeight: 1 }}></Typography>,
},
{
id: 'hinglish',
emoji: '🤝',
name: 'Hinglish',
subText: 'Mix',
icon: <TranslateIcon fontSize="large" />,
},
];
const tableLabel = selectedTable
? `${t('tableLabel')} ${selectedTable.table_number}`
: t('selectTable');
const openPicker = () => {
if (tableLocked) return;
setPickerOpen(true);
};
const handleStart = () => {
if (!selectedTable && !tableLocked) {
setPickerOpen(true);
return;
}
onStartOrdering(selectedLang);
};
return (
<Box
sx={{
@ -67,7 +114,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
flexDirection: 'column',
}}
>
{/* Hero Image Banner */}
<Box
sx={{
height: 260,
@ -78,7 +124,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
flexShrink: 0,
}}
>
{/* Dark overlay gradient */}
<Box
sx={{
position: 'absolute',
@ -87,7 +132,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
}}
/>
{/* Top bar inside hero */}
<Box
sx={{
position: 'absolute',
@ -118,26 +162,23 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
<RestaurantMenuIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#fff', letterSpacing: '-0.02em' }}>
RestroAI
{t('brand')}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`🪑 Table ${(() => {
const params = new URLSearchParams(window.location.search);
const tableParam = params.get('table');
if (tableParam) {
const parsed = parseInt(tableParam);
if (!isNaN(parsed)) return parsed;
clickable={!tableLocked}
onClick={openPicker}
icon={
tableLocked ? (
<LockIcon sx={{ color: '#fff !important', fontSize: 16 }} />
) : (
<ExpandMoreIcon sx={{ color: '#fff !important', fontSize: 18 }} />
)
}
const path = window.location.pathname;
const match = path.match(/\/scan\/(\d+)/);
if (match) {
return parseInt(match[1]);
}
return 12;
})()}`}
label={tablesLoading ? '…' : `🪑 ${tableLabel}`}
title={tableLocked ? t('tableLockedHint') : t('tablePickHint')}
sx={{
fontWeight: 800,
fontFamily: '"JetBrains Mono", monospace',
@ -145,15 +186,21 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
color: '#fff',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.35)',
cursor: tableLocked ? 'default' : 'pointer',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
<IconButton size="small" onClick={onOpenStaffLogin} sx={{ color: 'rgba(255,255,255,0.7)' }}>
<LockIcon fontSize="small" />
<IconButton
size="small"
onClick={() => (onOpenStaffLogin ? onOpenStaffLogin() : (window.location.href = '/staff.html'))}
sx={{ color: 'rgba(255,255,255,0.7)' }}
aria-label={t('staffLogin')}
>
<BadgeIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Hero text at bottom of image */}
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, px: 2.5, pb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 0.5 }}>
<AutoAwesomeIcon sx={{ fontSize: 14, color: '#ffb5a0' }} />
@ -170,10 +217,48 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
</Box>
</Box>
{/* Content Area */}
<Box sx={{ flex: 1, overflow: 'auto', pb: 4 }}>
<Container maxWidth="xs" sx={{ pt: 3 }}>
{/* AI Voice Section */}
{tablesError && (
<Alert severity="warning" sx={{ mb: 2, fontWeight: 600 }}>
{tablesError}
</Alert>
)}
{!tableLocked && (
<Paper
elevation={0}
onClick={openPicker}
sx={{
p: 2,
mb: 2,
borderRadius: '16px',
border: '1.5px dashed #e4beb4',
bgcolor: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068', textTransform: 'uppercase' }}>
{t('selectTable')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{selectedTable ? `Table ${selectedTable.table_number}` : t('tablePickHint')}
</Typography>
</Box>
<ExpandMoreIcon sx={{ color: '#ac2d00' }} />
</Paper>
)}
{tableLocked && selectedTable && (
<Alert severity="info" icon={<LockIcon />} sx={{ mb: 2, fontWeight: 600 }}>
{t('tableLockedHint')} Table {selectedTable.table_number}
</Alert>
)}
<Paper
elevation={0}
sx={{
@ -201,14 +286,13 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
color: '#fff',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(172,45,0,0.4)',
animation: 'pulse 2.5s ease-in-out infinite',
}}
>
<MicIcon />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', lineHeight: 1.2 }}>
Order with Voice 🎙
{t('voiceOrder')}
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
Tap & say your order in English or Hindi
@ -218,7 +302,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
</Box>
</Paper>
{/* Language Selection */}
<Box sx={{ mb: 3 }}>
<Typography
variant="caption"
@ -231,7 +314,7 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
textTransform: 'uppercase',
}}
>
Choose Your Language
{t('chooseLanguage')}
</Typography>
<Grid container spacing={1.5}>
@ -241,7 +324,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
<Grid size={{ xs: 4 }} key={lang.id}>
<Paper
elevation={isSelected ? 3 : 0}
onClick={() => setSelectedLang(lang.id as 'english' | 'hindi' | 'hinglish')}
onClick={() => {
const next = lang.id as 'english' | 'hindi' | 'hinglish';
setSelectedLang(next);
setLocale(uiLangToLocale(next));
}}
sx={{
p: 2,
borderRadius: '16px',
@ -271,7 +358,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
</Grid>
</Box>
{/* Today's Highlights */}
<Paper
elevation={0}
sx={{
@ -300,12 +386,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
</Box>
</Paper>
{/* Main CTA Button */}
<Button
fullWidth
variant="contained"
size="large"
onClick={() => onStartOrdering(selectedLang)}
onClick={handleStart}
endIcon={<ArrowForwardIcon />}
sx={{
py: 2,
@ -321,10 +406,9 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
transition: 'all 0.25s ease',
}}
>
Browse Full Menu
{t('startOrdering')}
</Button>
{/* Footer */}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 1, color: '#8f7068' }}>
<WifiIcon fontSize="small" />
<Typography variant="caption" sx={{ fontWeight: 600 }}>
@ -333,6 +417,39 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
</Box>
</Container>
</Box>
<Dialog open={pickerOpen} onClose={() => setPickerOpen(false)} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 900 }}>{t('selectTable')}</DialogTitle>
<DialogContent dividers>
{tablesLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={32} />
</Box>
)}
{!tablesLoading && availableTables.length === 0 && (
<Alert severity="warning">{t('noTablesAvailable')}</Alert>
)}
<List disablePadding>
{availableTables.map((table) => (
<ListItemButton
key={table.id}
selected={selectedTable?.id === table.id}
onClick={() => {
onSelectTable(table);
setPickerOpen(false);
}}
sx={{ borderRadius: 2, mb: 0.5 }}
>
<ListItemText
primary={`Table ${table.table_number}`}
secondary={`Seats ${table.capacity} · ${table.status}`}
primaryTypographyProps={{ fontWeight: 800 }}
/>
</ListItemButton>
))}
</List>
</DialogContent>
</Dialog>
</Box>
);
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Box,
Drawer,
@ -10,6 +10,8 @@ import {
Button,
Divider,
Fade,
CircularProgress,
TextField,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import MicIcon from '@mui/icons-material/Mic';
@ -18,58 +20,32 @@ import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData';
import SendIcon from '@mui/icons-material/Send';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
import type { VoiceState } from '../../hooks/useSpeechRecognition';
import { api } from '../../services/api';
import { debugLog, maskToken } from '../../utils/debugLog';
interface DetectedItem {
menuItem: MenuItem;
qty: number;
}
export type AiCartLine = {
menu_item_id: number;
name: string;
quantity: number;
unit_price: number | string;
notes: string[];
is_available: boolean;
};
interface VoiceAssistantModalProps {
open: boolean;
onClose: () => void;
onAddToCart: (itemId: string, quantity: number) => void;
}
// Detect menu items from voice transcript
function parseTranscriptToItems(text: string): DetectedItem[] {
const lower = text.toLowerCase();
const detectedItems: DetectedItem[] = [];
for (const item of MENU_ITEMS) {
const itemNameLower = item.name.toLowerCase();
// Check for item name in transcript
if (lower.includes(itemNameLower) || (item.nameHindi && lower.includes(item.nameHindi.toLowerCase()))) {
// Try to detect quantity (look for digits near the item name)
let qty = 1;
const qtxMatch = lower.match(/(\d+)\s*(?:plate|order|piece|nos|number)?(?:of\s+)?(?:\w+\s+)*/);
if (qtxMatch) {
const num = parseInt(qtxMatch[1], 10);
if (num >= 1 && num <= 10) qty = num;
}
if (/\btwo\b|\bdo\b/i.test(lower)) qty = 2;
if (/\bthree\b|\bteen\b/i.test(lower)) qty = 3;
if (/\bfour\b|\bchar\b/i.test(lower)) qty = 4;
// avoid duplicates
if (!detectedItems.find((d) => d.menuItem.id === item.id)) {
detectedItems.push({ menuItem: item, qty });
}
}
}
return detectedItems;
onSyncCart?: (cart: AiCartLine[]) => void;
}
const QUICK_COMMANDS = [
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken and 2 garlic naan please' },
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani for table 12' },
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi" },
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken kam spicy and 2 garlic naan please' },
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani please' },
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi please" },
{ label: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
];
@ -78,11 +54,118 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
open,
onClose,
onAddToCart,
onSyncCart,
}) => {
const [internalVoiceState, setInternalVoiceState] = useState<VoiceState>('idle');
const [detectedItems, setDetectedItems] = useState<DetectedItem[]>([]);
const [cartLines, setCartLines] = useState<AiCartLine[]>([]);
const [assistantText, setAssistantText] = useState('');
const [toolCalls, setToolCalls] = useState<string[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [apiError, setApiError] = useState<string | null>(null);
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
const [liveTranscript, setLiveTranscript] = useState('');
const [typedOrder, setTypedOrder] = useState('');
const applyCart = useCallback(
(cart: AiCartLine[]) => {
setCartLines(cart);
if (onSyncCart) {
onSyncCart(cart);
return;
}
for (const line of cart) {
onAddToCart(String(line.menu_item_id), line.quantity);
}
},
[onAddToCart, onSyncCart]
);
const runAiTurn = useCallback(
async (text: string, submit = false) => {
const trimmed = text.trim();
if (!trimmed) {
debugLog.warn('voice', 'runAiTurn skipped — empty text');
setApiError('Say or type an order first.');
return;
}
if (busyRef.current) {
debugLog.warn('voice', 'runAiTurn skipped — already busy', { trimmed });
return;
}
const sessionToken = sessionStorage.getItem('customer_session_token');
if (!sessionToken) {
debugLog.error('voice', 'runAiTurn blocked — no session token');
setApiError('No table session — close this, pick a table, then Start ordering.');
return;
}
busyRef.current = true;
setBusy(true);
setApiError(null);
setAssistantText('');
setToolCalls([]);
setConfirmed(false);
setLiveTranscript(trimmed);
const looksHindi = /[\u0900-\u097F]/.test(trimmed);
const language = looksHindi || selectedLang.startsWith('hi') ? 'hi' : 'en';
const started = performance.now();
debugLog.info('voice', 'runAiTurn start', {
submit,
language,
selectedLang,
transcript: trimmed.slice(0, 160),
sessionToken: maskToken(sessionToken),
tableId: sessionStorage.getItem('customer_table_id'),
tableNumber: sessionStorage.getItem('customer_table_number'),
});
try {
const result = await api.aiChatTurn({ transcript: trimmed, language, submit });
debugLog.info('voice', 'runAiTurn success', {
ms: Math.round(performance.now() - started),
tools: result.tool_calls,
cart: (result.cart || []).map((c) => `${c.quantity}×${c.name}`),
orderId: result.order?.order_id,
assistant: (result.assistant_text || '').slice(0, 120),
});
setLiveTranscript(result.transcript || trimmed);
setAssistantText(result.assistant_text || '');
setToolCalls(result.tool_calls || []);
if (result.order) {
setConfirmed(true);
applyCart([]);
} else {
applyCart(result.cart || []);
}
} catch (err: unknown) {
const raw = err instanceof Error ? err.message : 'AI ordering failed';
const message =
raw.length > 160 || raw.includes('failed_generation') || raw.includes('tool_use_failed')
? 'AI ordering failed — please try again or use the menu.'
: raw;
debugLog.error('voice', 'runAiTurn failed', {
ms: Math.round(performance.now() - started),
raw: String(raw).slice(0, 300),
shown: message,
});
setApiError(message);
} finally {
busyRef.current = false;
setBusy(false);
}
},
[applyCart, selectedLang]
);
const runAiTurnRef = useRef(runAiTurn);
useEffect(() => {
runAiTurnRef.current = runAiTurn;
}, [runAiTurn]);
const {
voiceState: recognitionState,
@ -93,101 +176,106 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
stopListening,
resetTranscript,
error,
} = useSpeechRecognition();
} = useSpeechRecognition((finalText) => {
debugLog.info('stt', 'final transcript → AI', { text: finalText.slice(0, 160) });
void runAiTurnRef.current(finalText, false);
});
const { speak, cancel } = useSpeechSynthesis((s) => setInternalVoiceState(s));
const voiceState = internalVoiceState !== 'idle' ? internalVoiceState : recognitionState;
// Parse items when transcript updates
useEffect(() => {
if (transcript.trim().length > 0 && recognitionState === 'thinking') {
const items = parseTranscriptToItems(transcript);
setDetectedItems(items);
setInternalVoiceState('thinking');
// AI confirmation response via TTS
if (items.length > 0) {
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
setTimeout(() => {
speak(
`Got it! I found ${items.length} item${items.length > 1 ? 's' : ''}: ${itemList}. Please confirm to add them to your cart.`,
if (!open) return;
debugLog.info('voice', 'modal opened', {
isSupported,
selectedLang,
() => setInternalVoiceState('idle')
);
}, 500);
} else {
setTimeout(() => {
speak(
"Sorry, I couldn't identify any items from your order. Please try again or use the quick commands below.",
selectedLang,
() => setInternalVoiceState('idle')
);
setInternalVoiceState('idle');
}, 400);
}
}
}, [transcript, recognitionState]);
const handleClose = useCallback(() => {
cancel();
resetTranscript();
setDetectedItems([]);
hasSession: Boolean(sessionStorage.getItem('customer_session_token')),
tableId: sessionStorage.getItem('customer_table_id'),
tableNumber: sessionStorage.getItem('customer_table_number'),
debug: debugLog.enabled(),
});
busyRef.current = false;
setBusy(false);
setApiError(null);
setAssistantText('');
setToolCalls([]);
setConfirmed(false);
setInternalVoiceState('idle');
setLiveTranscript('');
setTypedOrder('');
setCartLines([]);
resetTranscript();
}, [open, resetTranscript, isSupported, selectedLang]);
const voiceState: VoiceState = busy
? 'thinking'
: recognitionState === 'listening'
? 'listening'
: 'idle';
const handleClose = () => {
stopListening();
resetTranscript();
busyRef.current = false;
setBusy(false);
onClose();
}, [cancel, resetTranscript, onClose]);
};
const handleMicToggle = () => {
if (voiceState === 'listening') {
if (busyRef.current) {
debugLog.warn('stt', 'mic toggle ignored — AI busy');
return;
}
if (!isSupported) {
debugLog.warn('stt', 'mic unsupported');
setApiError('Speech recognition is unavailable here. Type your order or tap a quick command.');
return;
}
if (recognitionState === 'listening') {
debugLog.info('stt', 'mic stop requested');
stopListening();
} else {
setDetectedItems([]);
setConfirmed(false);
return;
}
debugLog.info('stt', 'mic start', { lang: selectedLang });
setApiError(null);
resetTranscript();
startListening(selectedLang);
}
};
const handleQuickCommand = (text: string) => {
const items = parseTranscriptToItems(text);
setDetectedItems(items);
setInternalVoiceState('thinking');
if (items.length > 0) {
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
speak(
`Great choice! Found ${itemList}. Tap confirm to add to your cart.`,
selectedLang,
() => setInternalVoiceState('idle')
);
}
debugLog.info('voice', 'quick command', { text: text.slice(0, 120) });
void runAiTurn(text, false);
};
const handleTypedSubmit = () => {
debugLog.info('voice', 'typed submit', { text: typedOrder.slice(0, 120) });
void runAiTurn(typedOrder, false);
};
const handleConfirm = () => {
detectedItems.forEach((d) => onAddToCart(d.menuItem.id, d.qty));
setConfirmed(true);
speak('Items added to your cart! Enjoy your meal.', selectedLang);
setTimeout(() => {
handleClose();
}, 2000);
const text =
liveTranscript.trim() ||
'Please place my current cart as an order. I confirm submit.';
void runAiTurn(text, true);
};
const orbColor = {
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' },
listening: { bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)', shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)' },
listening: {
bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)',
shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)',
},
thinking: { bg: 'linear-gradient(135deg, #845000, #b07000)', shadow: '0 8px 24px rgba(132,80,0,0.5)' },
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 0 0 16px rgba(0,106,46,0.2), 0 0 0 32px rgba(0,106,46,0.1)' },
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 8px 24px rgba(0,106,46,0.4)' },
error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
};
const orbLabel = {
idle: 'Tap to Speak',
listening: '🎙️ Listening...',
thinking: '🤔 Processing...',
speaking: '🔊 AI Speaking...',
error: '⚠️ Try Again',
idle: isSupported ? 'Tap to Speak' : 'Type or use quick commands',
listening: 'Listening… tap again to stop',
thinking: 'AI thinking…',
speaking: 'Done',
error: 'Try Again',
};
const shownTranscript = liveTranscript || transcript || interimTranscript;
return (
<Drawer
anchor="bottom"
@ -205,12 +293,10 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}>
{/* Handle Bar */}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
</Box>
{/* Header */}
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
@ -219,7 +305,7 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
AI Voice Assistant
</Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}>
Speak your order naturally in English or Hindi
Type, tap a quick command, or speak Groq fills your cart
</Typography>
</Box>
</Box>
@ -228,51 +314,52 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</IconButton>
</Box>
{/* Scrollable Body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
{!isSupported && (
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}>
Voice recognition requires Google Chrome browser. You can still use quick commands below.
Speech recognition API is missing in this browser. Type your order or use a quick command.
</Alert>
)}
{error && (
{isSupported && (
<Alert severity="info" sx={{ mb: 2, fontWeight: 600 }}>
Mic tip (Chrome): tap the orange button, speak, then tap again (or Stop) to send the order to AI.
</Alert>
)}
{(error || apiError) && (
<Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
{error}
{apiError || error}
</Alert>
)}
{/* Voice Orb */}
<Box sx={{ textAlign: 'center', py: 3 }}>
<Box sx={{ textAlign: 'center', py: 2 }}>
<Box
onClick={handleMicToggle}
sx={{
width: 120,
height: 120,
width: 112,
height: 112,
borderRadius: '50%',
background: orbColor[voiceState].bg,
boxShadow: orbColor[voiceState].shadow,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: voiceState === 'listening' || voiceState === 'speaking' ? 'scale(1.12)' : 'scale(1)',
mb: 2,
cursor: busy ? 'wait' : 'pointer',
mb: 1.5,
pointerEvents: busy ? 'none' : 'auto',
}}
>
{voiceState === 'listening' ? (
<GraphicEqIcon sx={{ color: '#fff', fontSize: 52 }} />
) : voiceState === 'speaking' ? (
<VolumeUpIcon sx={{ color: '#fff', fontSize: 52 }} />
) : voiceState === 'thinking' ? (
<SmartToyIcon sx={{ color: '#fff', fontSize: 52 }} />
{busy ? (
<CircularProgress size={40} sx={{ color: '#fff' }} />
) : voiceState === 'listening' ? (
<GraphicEqIcon sx={{ color: '#fff', fontSize: 48 }} />
) : (
<MicIcon sx={{ color: '#fff', fontSize: 52 }} />
<MicIcon sx={{ color: '#fff', fontSize: 48 }} />
)}
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
{orbLabel[voiceState]}
</Typography>
@ -281,22 +368,21 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
variant="outlined"
size="small"
startIcon={<StopIcon />}
onClick={stopListening}
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00' }}
onClick={handleMicToggle}
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00', mb: 1 }}
>
Stop & Process
</Button>
)}
{/* Language Toggle */}
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1.5 }}>
{['en-IN', 'hi-IN'].map((lang) => (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1 }}>
{(['en-IN', 'hi-IN'] as const).map((lang) => (
<Chip
key={lang}
label={lang === 'en-IN' ? '🇮🇳 English' : '🇮🇳 हिन्दी'}
label={lang === 'en-IN' ? 'English' : 'हिन्दी'}
size="small"
clickable
onClick={() => setSelectedLang(lang as 'en-IN' | 'hi-IN')}
onClick={() => setSelectedLang(lang)}
color={selectedLang === lang ? 'primary' : 'default'}
variant={selectedLang === lang ? 'filled' : 'outlined'}
sx={{ fontWeight: 700 }}
@ -305,8 +391,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Box>
</Box>
{/* Live Transcript */}
{(transcript || interimTranscript) && (
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
fullWidth
size="small"
placeholder='Type order e.g. "2 butter chicken and garlic naan"'
value={typedOrder}
disabled={busy}
onChange={(e) => setTypedOrder(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleTypedSubmit();
}}
/>
<Button
variant="contained"
disabled={busy || !typedOrder.trim()}
onClick={handleTypedSubmit}
sx={{ fontWeight: 800, bgcolor: '#ac2d00', px: 2, whiteSpace: 'nowrap' }}
>
Send
</Button>
</Box>
{shownTranscript && (
<Paper
elevation={0}
sx={{
@ -318,18 +425,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}}
>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
WHAT I HEARD:
ORDER TEXT:
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
"{transcript}{interimTranscript && <span style={{ color: '#9e9e9e' }}>{interimTranscript}</span>}"
"{shownTranscript}"
</Typography>
</Paper>
)}
{/* Detected Items */}
<Fade in={detectedItems.length > 0}>
{assistantText && (
<Alert severity="success" sx={{ mb: 2, fontWeight: 600 }}>
{assistantText}
</Alert>
)}
{toolCalls.length > 0 && (
<Typography variant="caption" sx={{ display: 'block', mb: 1, color: '#8f7068', fontWeight: 700 }}>
Tools: {toolCalls.join(' → ')}
</Typography>
)}
<Fade in={cartLines.length > 0}>
<Box>
{detectedItems.length > 0 && (
{cartLines.length > 0 && (
<Paper
elevation={0}
sx={{
@ -341,11 +459,11 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
Items Detected:
Cart from AI:
</Typography>
{detectedItems.map((d) => (
{cartLines.map((line) => (
<Box
key={d.menuItem.id}
key={`${line.menu_item_id}-${line.quantity}-${line.notes.join(',')}`}
sx={{
display: 'flex',
justifyContent: 'space-between',
@ -356,15 +474,17 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{d.qty}× {d.menuItem.name}
{line.quantity}× {line.name}
</Typography>
<Typography variant="caption" sx={{ color: '#546067' }}>
{(d.qty * d.menuItem.price).toFixed(0)}
{(Number(line.unit_price) * line.quantity).toFixed(0)}
{line.notes?.length ? ` · ${line.notes.join(', ')}` : ''}
</Typography>
</Box>
<Chip
label={d.menuItem.dietary === 'non-veg' ? '🔴 Non-Veg' : '🟢 Veg'}
label={line.is_available ? 'Available' : "86'd"}
size="small"
color={line.is_available ? 'success' : 'warning'}
sx={{ fontWeight: 700 }}
/>
</Box>
@ -375,24 +495,27 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
fullWidth
variant="outlined"
size="small"
onClick={() => { setDetectedItems([]); resetTranscript(); }}
onClick={() => {
setCartLines([]);
setAssistantText('');
setLiveTranscript('');
setToolCalls([]);
onSyncCart?.([]);
}}
sx={{ fontWeight: 700 }}
>
Retry
Clear
</Button>
<Button
fullWidth
variant="contained"
size="large"
startIcon={confirmed ? <CheckCircleIcon /> : <AddShoppingCartIcon />}
startIcon={confirmed ? <CheckCircleIcon /> : <SendIcon />}
onClick={handleConfirm}
disabled={confirmed}
sx={{
fontWeight: 800,
bgcolor: confirmed ? '#006a2e' : '#ac2d00',
}}
disabled={confirmed || busy}
sx={{ fontWeight: 800, bgcolor: confirmed ? '#006a2e' : '#ac2d00' }}
>
{confirmed ? 'Added!' : 'Add to Cart'}
{confirmed ? 'Ordered!' : 'Confirm & Place Order'}
</Button>
</Box>
</Paper>
@ -400,7 +523,6 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Box>
</Fade>
{/* Quick Commands */}
<Divider sx={{ my: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
QUICK COMMANDS
@ -408,9 +530,9 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{QUICK_COMMANDS.map((cmd, idx) => (
{QUICK_COMMANDS.map((cmd) => (
<Paper
key={idx}
key={cmd.label}
elevation={0}
onClick={() => handleQuickCommand(cmd.text)}
sx={{
@ -418,18 +540,20 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
py: 1.5,
borderRadius: '12px',
border: '1px solid #e4beb4',
cursor: 'pointer',
cursor: busy ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1.5,
opacity: busy ? 0.6 : 1,
pointerEvents: busy ? 'none' : 'auto',
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
transition: 'all 0.15s ease',
}}
>
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
"{cmd.label}"
</Typography>
<AddShoppingCartIcon sx={{ ml: 'auto', color: '#ac2d00', fontSize: 16 }} />
</Paper>
))}
</Box>

View File

@ -18,6 +18,7 @@ import { KDSTicketCard } from './KDSTicketCard';
interface KDSKanbanProps {
orders: KDSOrder[];
busyOrderId?: string | null;
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
onAddSampleOrder: () => void;
@ -25,6 +26,7 @@ interface KDSKanbanProps {
export const KDSKanban: React.FC<KDSKanbanProps> = ({
orders,
busyOrderId = null,
onStatusChange,
onToggleItem,
onAddSampleOrder,
@ -50,26 +52,38 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
];
return (
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
<Box sx={{ width: '100%', maxWidth: '100%' }}>
{/* Top Filter Bar */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 3,
p: { xs: 1.5, md: 2 },
mb: 2,
display: 'flex',
flexWrap: 'wrap',
gap: 2,
gap: 1.5,
alignItems: 'center',
justifyContent: 'space-between',
border: '1px solid #e2e2e2',
borderRadius: '12px',
bgcolor: '#fff',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', display: 'flex', alignItems: 'center', gap: 1 }}>
<RestaurantMenuIcon />
KDS Kanban Board
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', minWidth: 0 }}>
<Typography
variant="h6"
sx={{
fontWeight: 800,
color: '#ac2d00',
display: 'flex',
alignItems: 'center',
gap: 1,
fontSize: '1.05rem',
whiteSpace: 'nowrap',
}}
>
<RestaurantMenuIcon fontSize="small" />
KDS Board
</Typography>
<ButtonGroup variant="outlined" size="small">
@ -124,19 +138,22 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
</Paper>
{/* 4 Column Kanban Board Grid */}
<Grid container spacing={2}>
<Grid container spacing={1.5} alignItems="stretch">
{columns.map((col) => {
const colOrders = filteredOrders.filter((o) => o.status === col.status);
return (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={col.status}>
<Grid size={{ xs: 12, sm: 6, lg: 3 }} key={col.status}>
<Paper
elevation={0}
sx={{
p: 1.5,
minHeight: '75vh',
p: 1.25,
minHeight: { xs: 320, lg: 'calc(100vh - 200px)' },
height: '100%',
backgroundColor: '#f6f7f8',
border: '1px solid #e4beb4',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Column Header */}
@ -145,12 +162,23 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
gap: 1,
mb: 1.5,
pb: 1,
borderBottom: '2px solid #e2e2e2',
flexShrink: 0,
}}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: col.color,
letterSpacing: '0.04em',
fontSize: '0.7rem',
lineHeight: 1.3,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: col.color, letterSpacing: '0.05em' }}>
{col.title}
</Typography>
<Chip
@ -165,16 +193,16 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
</Box>
{/* Column Tickets */}
<Box>
<Box sx={{ flexGrow: 1, overflowY: 'auto', minHeight: 0, pr: 0.25 }}>
{colOrders.length === 0 ? (
<Box
sx={{
p: 4,
p: 3,
textAlign: 'center',
color: '#8f7068',
border: '2px dashed #e2e2e2',
borderRadius: '8px',
mt: 2,
mt: 1,
}}
>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
@ -186,6 +214,7 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
<KDSTicketCard
key={order.id}
order={order}
busy={busyOrderId === order.id}
onStatusChange={onStatusChange}
onToggleItem={onToggleItem}
/>

View File

@ -11,6 +11,7 @@ import {
Chip,
IconButton,
Tooltip,
CircularProgress,
} from '@mui/material';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
@ -22,16 +23,17 @@ import { StatusBadge } from '../common/StatusBadge';
interface KDSTicketCardProps {
order: KDSOrder;
busy?: boolean;
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
}
export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
order,
busy = false,
onStatusChange,
onToggleItem,
}) => {
// Calculate SLA timer color
const isUrgent = order.timeElapsedMinutes >= 25 || order.priority === 'urgent';
const isWarning = order.timeElapsedMinutes >= 15 && order.timeElapsedMinutes < 25;
@ -62,34 +64,56 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
served: 'COMPLETED',
}[order.status];
const allItemsChecked = order.items.every((i) => i.completed);
const allItemsChecked = order.items.length > 0 && order.items.every((i) => i.completed);
const locationLabel = order.tableNumber
? `Table ${order.tableNumber}`
: order.customerName ||
(order.orderType === 'Takeaway'
? 'Takeaway'
: order.orderType === 'Delivery'
? 'Delivery'
: 'Walk-in');
return (
<Card
sx={{
mb: 2,
mb: 1.5,
position: 'relative',
borderLeft: isUrgent ? '5px solid #ba1a1a' : isWarning ? '5px solid #845000' : '5px solid #ac2d00',
borderLeft: isUrgent
? '5px solid #ba1a1a'
: isWarning
? '5px solid #845000'
: '5px solid #ac2d00',
transition: 'transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
overflow: 'hidden',
opacity: busy ? 0.75 : 1,
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
},
}}
>
{/* Ticket Header */}
<Box
sx={{
p: 1.5,
p: 1.25,
backgroundColor: '#f3f3f3',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
gap: 1,
borderBottom: '1px solid #e2e2e2',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontFamily: '"JetBrains Mono", monospace', fontWeight: 800 }}>
<Box sx={{ minWidth: 0, flex: '1 1 auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 800,
fontSize: '1rem',
lineHeight: 1.2,
}}
>
#{order.ticketNumber}
</Typography>
<StatusBadge type={order.orderType} />
@ -103,79 +127,102 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
/>
)}
</Box>
<Typography variant="caption" sx={{ color: '#5b4139', display: 'block', mt: 0.2 }}>
{order.tableNumber ? `Table ${order.tableNumber}` : order.customerName || 'Walk-in Guest'}
{order.serverName && ` • Server: ${order.serverName}`}
<Typography
variant="caption"
sx={{
color: '#5b4139',
display: 'block',
mt: 0.5,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: '0.72rem',
lineHeight: 1.35,
fontWeight: 600,
}}
>
{locationLabel}
{order.serverName ? ` · ${order.serverName}` : ''}
</Typography>
</Box>
{/* SLA Timer Badge */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.4,
gap: 0.4,
px: 0.85,
py: 0.35,
borderRadius: '6px',
backgroundColor: timerBg,
color: timerColor,
fontWeight: 700,
flexShrink: 0,
}}
>
<AccessTimeIcon fontSize="small" />
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.8rem' }}>
<AccessTimeIcon sx={{ fontSize: 16 }} />
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.75rem' }}>
{order.timeElapsedMinutes}m
</Typography>
</Box>
</Box>
{/* Ticket Body / Items */}
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{order.items.map((item) => (
<Box
key={item.id}
onClick={() => onToggleItem(order.id, item.id)}
onClick={() => !busy && onToggleItem(order.id, item.id)}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
p: 0.8,
gap: 1,
p: 0.75,
borderRadius: '6px',
cursor: 'pointer',
cursor: busy ? 'default' : 'pointer',
backgroundColor: item.completed ? '#f0eded' : 'transparent',
textDecoration: item.completed ? 'line-through' : 'none',
opacity: item.completed ? 0.6 : 1,
'&:hover': {
backgroundColor: '#f6f3f2',
},
opacity: item.completed ? 0.65 : 1,
'&:hover': busy ? undefined : { backgroundColor: '#f6f3f2' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, minWidth: 0, flex: 1 }}>
<Checkbox
checked={!!item.completed}
size="small"
sx={{ p: 0, mt: 0.2, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
disabled={busy}
sx={{ p: 0, mt: 0.15, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
/>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, flexWrap: 'wrap' }}>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontFamily: '"JetBrains Mono", monospace',
color: '#ac2d00',
flexShrink: 0,
}}
>
{item.quantity}x
{item.quantity}×
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography
variant="body2"
sx={{
fontWeight: 700,
textDecoration: item.completed ? 'line-through' : 'none',
lineHeight: 1.3,
wordBreak: 'break-word',
flex: '1 1 80px',
minWidth: 0,
}}
>
{item.name}
</Typography>
{item.dietary && <StatusBadge dietary={item.dietary} />}
{item.dietary && (
<Box sx={{ flexShrink: 0 }}>
<StatusBadge dietary={item.dietary} />
</Box>
)}
</Box>
{item.notes && (
<Typography
variant="caption"
@ -183,21 +230,24 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
color: '#ba1a1a',
fontWeight: 700,
display: 'block',
mt: 0.2,
mt: 0.35,
backgroundColor: '#ffdad6',
px: 0.8,
py: 0.2,
borderRadius: '4px',
}}
>
{item.notes}
{item.notes}
</Typography>
)}
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
${(item.price * item.quantity).toFixed(2)}
<Typography
variant="caption"
sx={{ color: '#5b4139', fontWeight: 700, flexShrink: 0, pt: 0.2 }}
>
{(item.price * item.quantity).toFixed(0)}
</Typography>
</Box>
))}
@ -206,14 +256,22 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
<Divider />
{/* Ticket Footer Actions */}
<CardActions sx={{ p: 1, justifyContent: 'space-between', backgroundColor: '#fafafa' }}>
<Box>
{prevStatus && (
<Tooltip title={`Revert to ${prevStatus}`}>
<IconButton size="small" onClick={() => onStatusChange(order.id, prevStatus)}>
<Tooltip title={`Move back to ${prevStatus}`}>
<span>
<IconButton
size="small"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
onStatusChange(order.id, prevStatus);
}}
>
<ArrowBackIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
</Box>
@ -222,12 +280,18 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
<Button
size="small"
variant="contained"
disabled={busy}
color={allItemsChecked ? 'success' : order.status === 'pending' ? 'primary' : 'warning'}
onClick={() => onStatusChange(order.id, nextStatus)}
endIcon={<ArrowForwardIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onStatusChange(order.id, nextStatus);
}}
endIcon={
busy ? <CircularProgress size={14} color="inherit" /> : <ArrowForwardIcon fontSize="small" />
}
sx={{ fontWeight: 800, fontSize: '0.75rem' }}
>
{actionButtonText}
{busy ? 'Updating…' : actionButtonText}
</Button>
) : (
<Chip

View File

@ -1,210 +1,359 @@
import React, { useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Typography,
Button,
Chip,
Grid,
Paper,
Divider,
TextField,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
Paper,
Divider,
} from '@mui/material';
import MicIcon from '@mui/icons-material/Mic';
import StopIcon from '@mui/icons-material/Stop';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import type { VoiceCommandSuggestion } from '../../types';
import InventoryIcon from '@mui/icons-material/Inventory';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import SendIcon from '@mui/icons-material/Send';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { api } from '../../services/api';
interface VoiceOrderingViewProps {
onAddOrderFromVoice: (transcript: string) => void;
}
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = ({ onAddOrderFromVoice }) => {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState(
'Do Butter Chicken, teen Garlic Naan aur ek Mango Lassi Table 12 ke liye'
);
const [detectedItems] = useState([
{ name: 'Butter Chicken', qty: 2, price: 360, dietary: 'non-veg' as const },
{ name: 'Garlic Naan', qty: 3, price: 80, dietary: 'veg' as const },
{ name: 'Mango Lassi', qty: 1, price: 120, dietary: 'veg' as const },
]);
const [submitted, setSubmitted] = useState(false);
const samplePrompts: VoiceCommandSuggestion[] = [
{
id: '1',
label: '2 Butter Chicken & Garlic Naan Table 12',
prompt: 'Do Butter Chicken aur do Garlic Naan Table 12 ke liye',
category: 'order',
},
{
id: '2',
label: 'Hyderabadi Biryani + Raita Table 8',
prompt: 'Ek Hyderabadi Chicken Biryani with extra raita for Table 8',
category: 'order',
},
{
id: '3',
label: 'Jain Palak Paneer no onion no garlic',
prompt: 'Add 1 Palak Paneer Jain preparation no onion no garlic for Table 4',
category: 'order',
},
{
id: '4',
label: 'Extra Cutlery + Tissue for Table 3',
prompt: 'Please send extra cutlery and tissue paper to Table 3',
category: 'action',
},
];
const handleConfirmOrder = () => {
onAddOrderFromVoice(transcript);
setSubmitted(true);
setTimeout(() => setSubmitted(false), 3000);
type LowStockRow = {
id: number;
name: string;
unit: string;
current_stock: number;
reorder_threshold: number;
is_low: boolean;
};
const totalValue = detectedItems.reduce((sum, i) => sum + i.price * i.qty, 0);
interface VoiceOrderingViewProps {
onAddOrderFromVoice?: (transcript: string) => void;
}
const QUICK_PROMPTS = [
{ label: 'What is low stock?', text: 'What ingredients are low on stock right now?' },
{ label: 'Add 2kg tomato', text: 'please add 2 kgs tomato in inventory' },
{ label: 'Chicken stock?', text: 'How much chicken do we have left?' },
{ label: 'Restock cream 1L', text: 'add 1 litre cream to inventory' },
{ label: 'Send alert now', text: 'Send a low stock alert notification now' },
{ label: 'Full inventory', text: 'Give me a quick inventory stock overview' },
];
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = () => {
const [typed, setTyped] = useState('');
const [liveTranscript, setLiveTranscript] = useState('');
const [assistantText, setAssistantText] = useState('');
const [toolCalls, setToolCalls] = useState<string[]>([]);
const [lowStock, setLowStock] = useState<LowStockRow[]>([]);
const [updates, setUpdates] = useState<LowStockRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
const busyRef = useRef(false);
const runStaffAi = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed || busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError(null);
setLiveTranscript(trimmed);
setAssistantText('');
try {
const language = selectedLang.startsWith('hi') || /[\u0900-\u097F]/.test(trimmed) ? 'hi' : 'en';
const result = await api.staffAiChatTurn({ transcript: trimmed, language });
setAssistantText(result.assistant_text || '');
setToolCalls(result.tool_calls || []);
setLowStock(result.low_stock || []);
setUpdates(result.updates || []);
setLiveTranscript(result.transcript || trimmed);
} catch (err) {
setError(err instanceof Error ? err.message : 'Staff AI failed');
} finally {
busyRef.current = false;
setBusy(false);
}
}, [selectedLang]);
const runStaffAiRef = useRef(runStaffAi);
useEffect(() => {
runStaffAiRef.current = runStaffAi;
}, [runStaffAi]);
const {
voiceState,
transcript,
interimTranscript,
isSupported,
startListening,
stopListening,
resetTranscript,
error: sttError,
} = useSpeechRecognition((finalText) => {
void runStaffAiRef.current(finalText);
});
useEffect(() => {
// Prefetch low stock so the panel isn't empty on first open.
void api
.getLowStockInventory()
.then((rows) => {
const list = Array.isArray(rows) ? rows : rows?.items || [];
setLowStock(
list.map((r: any) => ({
id: Number(r.id),
name: String(r.name),
unit: String(r.unit || ''),
current_stock: Number(r.current_stock ?? r.stock ?? 0),
reorder_threshold: Number(r.reorder_threshold ?? r.minStock ?? 0),
is_low: true,
})),
);
})
.catch(() => {
/* ignore prefetch errors */
});
}, []);
const toggleMic = () => {
if (voiceState === 'listening') {
stopListening();
const spoken = (transcript || interimTranscript || '').trim();
if (spoken) void runStaffAi(spoken);
return;
}
resetTranscript();
setLiveTranscript('');
startListening(selectedLang);
};
const shownTranscript = liveTranscript || transcript || interimTranscript;
const listening = voiceState === 'listening';
return (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box sx={{ p: { xs: 2, md: 3 }, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
Staff Voice Assistant Terminal
Staff AI Assistant
</Typography>
<Typography variant="body1" sx={{ color: '#5b4139' }}>
Hands-free voice intake for servers & kitchen staff. Supports Hindi, English & Hinglish.
Ask about inventory by voice or text get low-stock checks and send alerts anytime.
</Typography>
</Box>
{/* Voice Orb */}
<Grid container spacing={2.5}>
<Grid size={{ xs: 12, md: 5 }}>
<Paper
elevation={0}
sx={{
p: 4,
mb: 4,
textAlign: 'center',
borderRadius: '24px',
background: 'linear-gradient(180deg, #ffffff 0%, #ffdbd1 100%)',
p: 3,
borderRadius: '20px',
border: '1px solid #e4beb4',
bgcolor: '#fff9f7',
textAlign: 'center',
minHeight: 360,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}}
>
<Box
onClick={() => setIsListening(!isListening)}
onClick={toggleMic}
sx={{
width: 130,
height: 130,
width: 110,
height: 110,
borderRadius: '50%',
mx: 'auto',
mb: 3,
display: 'grid',
placeItems: 'center',
cursor: 'pointer',
background: isListening
? 'radial-gradient(circle, #d53e0b 0%, #ac2d00 70%)'
: 'radial-gradient(circle, #ac2d00 0%, #872100 100%)',
boxShadow: isListening
? '0 0 0 18px rgba(213, 62, 11, 0.2), 0 0 0 36px rgba(172, 45, 0, 0.1)'
: '0 4px 20px rgba(172, 45, 0, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
transition: 'all 0.3s ease-in-out',
transform: isListening ? 'scale(1.08)' : 'scale(1)',
color: '#fff',
background: listening
? 'linear-gradient(135deg, #d53e0b, #e75a2b)'
: busy
? 'linear-gradient(135deg, #845000, #b07000)'
: 'linear-gradient(135deg, #ac2d00, #d53e0b)',
boxShadow: listening
? '0 0 0 14px rgba(213,62,11,0.18)'
: '0 8px 24px rgba(172,45,0,0.35)',
}}
>
{isListening ? <GraphicEqIcon sx={{ fontSize: 60 }} /> : <MicIcon sx={{ fontSize: 60 }} />}
{busy ? <CircularProgress size={36} color="inherit" /> : listening ? <StopIcon sx={{ fontSize: 40 }} /> : <MicIcon sx={{ fontSize: 40 }} />}
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
{isListening ? '🎙️ Listening & Parsing Voice Input...' : 'Tap Orb or Speak to Begin Order'}
<Typography sx={{ fontWeight: 800, color: '#ac2d00' }}>
{busy ? 'AI checking inventory…' : listening ? 'Listening… tap to stop' : isSupported ? 'Tap to speak' : 'Type a question below'}
</Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600, display: 'block', mb: 3 }}>
Multilingual: English · Hindi · Hinglish
</Typography>
{/* Live Transcript */}
<Paper
elevation={0}
sx={{ p: 2.5, maxWidth: 700, mx: 'auto', bgcolor: '#ffffff', borderRadius: '12px', border: '2px solid #ac2d00', textAlign: 'left' }}
>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
LIVE TRANSCRIPT:
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
"{transcript}"
</Typography>
</Paper>
</Paper>
{/* Quick Prompts */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#546067', mb: 1.5, textTransform: 'uppercase' }}>
Suggested Quick Voice Commands
</Typography>
<Grid container spacing={1.5}>
{samplePrompts.map((s) => (
<Grid size={{ xs: 12, sm: 6 }} key={s.id}>
<Box sx={{ display: 'flex', gap: 1 }}>
{(['en-IN', 'hi-IN'] as const).map((lang) => (
<Chip
icon={<SmartToyIcon fontSize="small" />}
label={s.label}
onClick={() => { setTranscript(s.prompt); setIsListening(true); setTimeout(() => setIsListening(false), 1500); }}
clickable
sx={{ width: '100%', justifyContent: 'flex-start', py: 2.5, px: 1, fontWeight: 700, bgcolor: '#ffffff', border: '1px solid #e4beb4', '&:hover': { bgcolor: '#ffdbd1' } }}
key={lang}
label={lang === 'en-IN' ? 'English' : 'Hindi'}
size="small"
onClick={() => setSelectedLang(lang)}
color={selectedLang === lang ? 'primary' : 'default'}
sx={{ fontWeight: 700 }}
/>
</Grid>
))}
</Grid>
</Box>
{/* Detected Items */}
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>AI Extracted Items</Typography>
<Chip label="Table 12" color="primary" sx={{ fontWeight: 800 }} />
</Box>
<List disablePadding>
{detectedItems.map((item, idx) => (
<ListItem
key={idx}
sx={{ py: 1.5, px: 2, mb: 1, borderRadius: '8px', bgcolor: '#f6f7f8', display: 'flex', justifyContent: 'space-between' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h6" sx={{ color: '#ac2d00', fontWeight: 800, fontFamily: 'monospace' }}>
{item.qty}×
{(error || sttError) && (
<Alert severity="error" sx={{ width: '100%', textAlign: 'left', fontWeight: 600 }}>
{error || sttError}
</Alert>
)}
{shownTranscript && (
<Paper elevation={0} sx={{ p: 1.5, width: '100%', bgcolor: '#fff', border: '1px solid #f0ebe7', borderRadius: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068' }}>
You said
</Typography>
<ListItemText
primary={<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>}
secondary={`Subtotal: ₹${(item.qty * item.price)}`}
<Typography variant="body2" sx={{ fontWeight: 700 }}>{shownTranscript}</Typography>
</Paper>
)}
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 7 }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<SmartToyIcon sx={{ color: '#ac2d00' }} />
<Typography sx={{ fontWeight: 800 }}>AI reply</Typography>
</Box>
{assistantText ? (
<Alert
severity={updates.length ? 'info' : 'success'}
icon={<GraphicEqIcon />}
sx={{ fontWeight: 600, mb: 1.5 }}
>
{assistantText}
</Alert>
) : (
<Typography variant="body2" sx={{ color: '#8f7068', mb: 1.5 }}>
Try: Add 2 kg tomato or What is low stock?
</Typography>
)}
{updates.length > 0 && (
<Box sx={{ mb: 1.5 }}>
{updates.map((row) => (
<Chip
key={`upd-${row.id}`}
color="success"
sx={{ fontWeight: 800, mr: 1, mb: 1 }}
label={`Updated ${row.name}: ${row.current_stock}${row.unit}`}
/>
))}
</Box>
)}
{toolCalls.length > 0 && (
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 700 }}>
Tools: {toolCalls.join(' → ')}
</Typography>
)}
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
<TextField
fullWidth
size="small"
placeholder="Type: add 2 kg tomato / check cream stock…"
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && typed.trim()) {
void runStaffAi(typed);
setTyped('');
}
}}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: '12px' } }}
/>
<Button
variant="contained"
disabled={busy || !typed.trim()}
onClick={() => {
void runStaffAi(typed);
setTyped('');
}}
endIcon={<SendIcon />}
sx={{ fontWeight: 800, bgcolor: '#ac2d00', borderRadius: '12px', px: 2 }}
>
Ask
</Button>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{QUICK_PROMPTS.map((p) => (
<Chip
key={p.label}
label={p.label}
onClick={() => void runStaffAi(p.text)}
sx={{ fontWeight: 700 }}
/>
))}
</Box>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InventoryIcon sx={{ color: '#ac2d00' }} />
<Typography sx={{ fontWeight: 800 }}>Low stock now</Typography>
<Chip
size="small"
label={lowStock.length}
color={lowStock.length ? 'error' : 'success'}
sx={{ fontWeight: 800 }}
/>
</Box>
<Chip label={item.dietary.toUpperCase()} size="small" variant="outlined" sx={{ fontWeight: 700 }} />
<Button
size="small"
variant="outlined"
startIcon={<NotificationsActiveIcon />}
disabled={busy}
onClick={() => void runStaffAi('Send a low stock alert notification now')}
sx={{ fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
>
Notify
</Button>
</Box>
{lowStock.length === 0 ? (
<Typography variant="body2" sx={{ color: '#8f7068' }}>
No ingredients below reorder threshold.
</Typography>
) : (
<List dense disablePadding>
{lowStock.map((row) => (
<ListItem
key={row.id}
sx={{
px: 1,
py: 0.75,
mb: 0.5,
borderRadius: 1.5,
bgcolor: '#fff5f2',
border: '1px solid #ffdad6',
}}
>
<ListItemText
primary={
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem' }}>{row.name}</Typography>
}
secondary={`Now ${row.current_stock}${row.unit} · reorder at ${row.reorder_threshold}${row.unit}`}
/>
<Chip label="LOW" size="small" color="error" sx={{ fontWeight: 800 }} />
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Total: {totalValue}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
size="large"
disabled={submitted}
startIcon={submitted ? <CheckCircleOutlinedIcon /> : <AddShoppingCartIcon />}
onClick={handleConfirmOrder}
sx={{ fontWeight: 800, px: 4 }}
>
{submitted ? 'ORDER SENT TO KDS!' : 'DISPATCH TO KDS'}
</Button>
</Box>
)}
</Paper>
</Grid>
</Grid>
</Box>
);
};

View File

@ -1,5 +1,5 @@
export interface MenuItem {
id: string;
id: number;
name: string;
nameHindi?: string;
category: 'Starters' | 'Biryani' | 'Mains' | 'Breads' | 'Drinks' | 'Desserts' | 'Specials';
@ -12,12 +12,14 @@ export interface MenuItem {
image: string;
spiceLevel?: 'mild' | 'medium' | 'hot' | 'extra-hot';
tags?: string[];
/** Present when loaded from API; static fallback treats missing as available */
isAvailable?: boolean;
}
export const MENU_ITEMS: MenuItem[] = [
// ─── STARTERS ───────────────────────────────────────────────────
{
id: 'm1',
id: 1,
name: 'Samosa Chaat',
nameHindi: 'समोसा चाट',
category: 'Starters',
@ -31,7 +33,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Popular', 'Street Food'],
},
{
id: 'm2',
id: 2,
name: 'Paneer Tikka',
nameHindi: 'पनीर टिक्का',
category: 'Starters',
@ -45,7 +47,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ["Chef's Special", 'Tandoor'],
},
{
id: 'm3',
id: 3,
name: 'Chicken 65',
nameHindi: 'चिकन 65',
category: 'Starters',
@ -59,7 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['South Indian', 'Crispy'],
},
{
id: 'm4',
id: 4,
name: 'Hara Bhara Kabab',
nameHindi: 'हरा भरा कबाब',
category: 'Starters',
@ -72,7 +74,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Healthy', 'Spinach'],
},
{
id: 'm5',
id: 5,
name: 'Seekh Kebab',
nameHindi: 'सीख कबाब',
category: 'Starters',
@ -88,7 +90,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── BIRYANI ────────────────────────────────────────────────────
{
id: 'm6',
id: 6,
name: 'Hyderabadi Chicken Biryani',
nameHindi: 'हैदराबादी चिकन बिरयानी',
category: 'Biryani',
@ -103,7 +105,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Dum Style', 'Hyderabadi', 'Bestseller'],
},
{
id: 'm7',
id: 7,
name: 'Veg Dum Biryani',
nameHindi: 'वेज दम बिरयानी',
category: 'Biryani',
@ -116,7 +118,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Dum Style', 'Veg'],
},
{
id: 'm8',
id: 8,
name: 'Mutton Biryani',
nameHindi: 'मटन बिरयानी',
category: 'Biryani',
@ -131,7 +133,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── MAINS ──────────────────────────────────────────────────────
{
id: 'm9',
id: 9,
name: 'Butter Chicken',
nameHindi: 'बटर चिकन',
category: 'Mains',
@ -145,7 +147,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Classic', 'Bestseller'],
},
{
id: 'm10',
id: 10,
name: 'Dal Makhani',
nameHindi: 'दाल मखनी',
category: 'Mains',
@ -159,7 +161,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Punjabi', 'Classic'],
},
{
id: 'm11',
id: 11,
name: 'Palak Paneer',
nameHindi: 'पालक पनीर',
category: 'Mains',
@ -172,7 +174,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Healthy', 'Jain Available'],
},
{
id: 'm12',
id: 12,
name: 'Chicken Kadhai',
nameHindi: 'चिकन कड़ाही',
category: 'Mains',
@ -186,7 +188,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Kadhai Style', "Chef's Special"],
},
{
id: 'm13',
id: 13,
name: 'Paneer Butter Masala',
nameHindi: 'पनीर बटर मसाला',
category: 'Mains',
@ -199,7 +201,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Popular'],
},
{
id: 'm14',
id: 14,
name: 'Rogan Josh',
nameHindi: 'रोगन जोश',
category: 'Mains',
@ -214,7 +216,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── BREADS ──────────────────────────────────────────────────────
{
id: 'm15',
id: 15,
name: 'Butter Naan',
nameHindi: 'बटर नान',
category: 'Breads',
@ -226,7 +228,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Tandoor'],
},
{
id: 'm16',
id: 16,
name: 'Garlic Naan',
nameHindi: 'लहसुन नान',
category: 'Breads',
@ -239,7 +241,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Bestseller', 'Tandoor'],
},
{
id: 'm17',
id: 17,
name: 'Lachha Paratha',
nameHindi: 'लच्छा पराठा',
category: 'Breads',
@ -253,7 +255,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── DRINKS ──────────────────────────────────────────────────────
{
id: 'm18',
id: 18,
name: 'Mango Lassi',
nameHindi: 'आम की लस्सी',
category: 'Drinks',
@ -266,7 +268,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Summer Special', 'Fresh'],
},
{
id: 'm19',
id: 19,
name: 'Masala Chai',
nameHindi: 'मसाला चाय',
category: 'Drinks',
@ -278,7 +280,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Kulhad', 'Hot'],
},
{
id: 'm20',
id: 20,
name: 'Sweet Lime Soda',
nameHindi: 'नींबू सोडा',
category: 'Drinks',
@ -290,7 +292,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Fresh', 'Refreshing'],
},
{
id: 'm21',
id: 21,
name: 'Rose Sharbat',
nameHindi: 'गुलाब शरबत',
category: 'Drinks',
@ -304,7 +306,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── DESSERTS ────────────────────────────────────────────────────
{
id: 'm22',
id: 22,
name: 'Gulab Jamun',
nameHindi: 'गुलाब जामुन',
category: 'Desserts',
@ -317,7 +319,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Hot', 'Classic'],
},
{
id: 'm23',
id: 23,
name: 'Rasmalai',
nameHindi: 'रसमलाई',
category: 'Desserts',
@ -330,7 +332,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Bengali', "Chef's Special", 'Cold'],
},
{
id: 'm24',
id: 24,
name: 'Gajar Halwa',
nameHindi: 'गाजर का हलवा',
category: 'Desserts',
@ -344,7 +346,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ─── SPECIALS ─────────────────────────────────────────────────────
{
id: 'm25',
id: 25,
name: 'Chef\'s Thali',
nameHindi: 'शेफ की थाली',
category: 'Specials',
@ -358,7 +360,7 @@ export const MENU_ITEMS: MenuItem[] = [
tags: ['Thali', 'Value', 'Complete Meal'],
},
{
id: 'm26',
id: 26,
name: 'Non-Veg Thali',
nameHindi: 'नॉन-वेज थाली',
category: 'Specials',

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { debugLog } from '../utils/debugLog';
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
@ -43,12 +44,28 @@ interface UseSpeechRecognitionReturn {
error: string | null;
}
export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
type FinalHandler = (text: string) => void;
/**
* Chrome Web Speech (webkitSpeechRecognition).
* Submits final text on end; if Chrome only left interim results, submits those too.
*/
export const useSpeechRecognition = (
onFinalTranscript?: FinalHandler
): UseSpeechRecognitionReturn => {
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
const [transcript, setTranscript] = useState<string>('');
const [interimTranscript, setInterimTranscript] = useState<string>('');
const [transcript, setTranscript] = useState('');
const [interimTranscript, setInterimTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<ISpeechRecognition | null>(null);
const finalBufferRef = useRef('');
const interimRef = useRef('');
const submittedRef = useRef(false);
const onFinalRef = useRef<FinalHandler | undefined>(onFinalTranscript);
useEffect(() => {
onFinalRef.current = onFinalTranscript;
}, [onFinalTranscript]);
const isSupported =
typeof window !== 'undefined' &&
@ -56,84 +73,160 @@ export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
useEffect(() => {
return () => {
try {
recognitionRef.current?.abort();
} catch {
/* ignore */
}
};
}, []);
const startListening = useCallback((lang: string = 'en-IN') => {
const emitFinal = useCallback(() => {
if (submittedRef.current) return;
const text = (finalBufferRef.current || interimRef.current).trim();
if (!text) {
debugLog.warn('stt', 'emitFinal skipped — empty buffer');
return;
}
submittedRef.current = true;
setTranscript(text);
setInterimTranscript('');
debugLog.info('stt', 'emitFinal', {
source: finalBufferRef.current.trim() ? 'final' : 'interim-fallback',
text: text.slice(0, 160),
});
onFinalRef.current?.(text);
}, []);
const startListening = useCallback(
(lang: string = 'en-IN') => {
if (!isSupported) {
setError('Voice recognition is not supported in this browser. Please use Chrome.');
setError('Voice recognition is not supported in this browser.');
setVoiceState('error');
return;
}
try {
recognitionRef.current?.abort();
} catch {
/* ignore */
}
finalBufferRef.current = '';
interimRef.current = '';
submittedRef.current = false;
setTranscript('');
setInterimTranscript('');
setError(null);
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognitionAPI();
recognitionRef.current = recognition;
recognition.lang = lang;
recognition.continuous = false;
// continuous=true: user taps stop; Chrome is more reliable this way than auto-end.
recognition.lang = lang === 'hi-IN' ? 'hi-IN' : 'en-IN';
recognition.continuous = true;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onstart = () => {
debugLog.info('stt', 'recognition onstart', { lang: recognition.lang });
setVoiceState('listening');
setError(null);
setInterimTranscript('');
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = '';
let final = '';
let finals = finalBufferRef.current;
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const piece = result[0]?.transcript || '';
if (result.isFinal) {
final += result[0].transcript;
finals = `${finals} ${piece}`.trim();
} else {
interim += result[0].transcript;
interim += piece;
}
}
finalBufferRef.current = finals;
interimRef.current = interim;
setTranscript(finals);
setInterimTranscript(interim);
if (final) {
setTranscript((prev) => prev + ' ' + final.trim());
setVoiceState('thinking');
}
debugLog.info('stt', 'recognition onresult', {
final: finals.slice(0, 120),
interim: interim.slice(0, 120),
});
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
// Chrome fires "aborted" when we restart/stop — not a real failure.
if (event.error === 'aborted' || event.error === 'no-speech') {
debugLog.info('stt', 'recognition soft-error ignored', { error: event.error });
return;
}
debugLog.error('stt', 'recognition onerror', { error: event.error });
const errorMessages: Record<string, string> = {
'no-speech': 'No speech detected. Please try again.',
'audio-capture': 'Microphone not accessible.',
'not-allowed': 'Microphone permission denied. Please allow access.',
'network': 'Network error. Check your internet connection.',
'audio-capture': 'Microphone not accessible. Check Chrome site permissions.',
'not-allowed': 'Microphone blocked. Allow mic for localhost in Chrome.',
network:
'Chrome speech service network error. Use the text box or a quick command.',
'service-not-allowed': 'Chrome speech service blocked. Use text / quick command.',
};
setError(errorMessages[event.error] || `Voice error: ${event.error}`);
setVoiceState('error');
};
recognition.onend = () => {
setInterimTranscript('');
if (voiceState === 'listening') {
setVoiceState('thinking');
}
debugLog.info('stt', 'recognition onend', {
finalBuf: finalBufferRef.current.slice(0, 120),
interimBuf: interimRef.current.slice(0, 120),
submitted: submittedRef.current,
});
setVoiceState('idle');
emitFinal();
};
try {
recognition.start();
} catch (e) {
setError('Could not start voice recognition.');
debugLog.info('stt', 'recognition.start() called', { lang: recognition.lang, continuous: true });
} catch (err) {
debugLog.error('stt', 'recognition.start() threw', {
error: err instanceof Error ? err.message : String(err),
});
setError('Could not start Chrome speech recognition. Try the text box.');
setVoiceState('error');
}
}, [isSupported, voiceState]);
},
[emitFinal, isSupported]
);
const stopListening = useCallback(() => {
recognitionRef.current?.stop();
setVoiceState('thinking');
}, []);
const recognition = recognitionRef.current;
if (!recognition) {
emitFinal();
setVoiceState('idle');
return;
}
try {
recognition.stop();
} catch {
emitFinal();
setVoiceState('idle');
}
// Chrome sometimes delays onend — don't leave the guest hanging.
window.setTimeout(() => {
if (!submittedRef.current) {
emitFinal();
setVoiceState('idle');
}
}, 600);
}, [emitFinal]);
const resetTranscript = useCallback(() => {
finalBufferRef.current = '';
interimRef.current = '';
submittedRef.current = false;
setTranscript('');
setInterimTranscript('');
setVoiceState('idle');

View File

@ -15,7 +15,10 @@ export const useSpeechSynthesis = (
const speak = useCallback(
(text: string, lang: string = 'en-IN', onEnd?: () => void) => {
if (!isSupported) return;
if (!isSupported) {
onEnd?.();
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
@ -24,20 +27,27 @@ export const useSpeechSynthesis = (
utterance.pitch = 1.05;
utterance.volume = 1;
utterance.onstart = () => {
setVoiceState?.('speaking');
};
utterance.onend = () => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
setVoiceState?.('idle');
onEnd?.();
};
utterance.onerror = () => {
setVoiceState?.('idle');
utterance.onstart = () => {
setVoiceState?.('speaking');
};
utterance.onend = finish;
utterance.onerror = finish;
try {
window.speechSynthesis.speak(utterance);
// Firefox can leave speech pending without onend — unblock UI.
window.setTimeout(finish, 5000);
} catch {
finish();
}
},
[isSupported, setVoiceState]
);

View File

@ -0,0 +1,59 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import {
messages,
toAppLocale,
type AppLocale,
type MessageKey,
} from './messages';
const STORAGE_KEY = 'customer_locale';
type LocaleContextValue = {
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
t: (key: MessageKey) => string;
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function readStoredLocale(): AppLocale {
try {
return toAppLocale(sessionStorage.getItem(STORAGE_KEY));
} catch {
return 'en';
}
}
export const LocaleProvider: React.FC<{ children: React.ReactNode; initial?: AppLocale }> = ({
children,
initial,
}) => {
const [locale, setLocaleState] = useState<AppLocale>(initial || readStoredLocale());
const setLocale = useCallback((next: AppLocale) => {
setLocaleState(next);
try {
sessionStorage.setItem(STORAGE_KEY, next);
document.documentElement.lang = next === 'hi' ? 'hi' : 'en';
} catch {
/* ignore */
}
}, []);
const t = useCallback(
(key: MessageKey) => messages[locale][key] || messages.en[key] || key,
[locale],
);
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
};
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error('useLocale must be used within LocaleProvider');
}
return ctx;
}

134
src/i18n/messages.ts Normal file
View File

@ -0,0 +1,134 @@
export type AppLocale = 'en' | 'hi' | 'hinglish';
export type MessageKey =
| 'brand'
| 'welcome'
| 'tableLabel'
| 'selectTable'
| 'tableLockedHint'
| 'tablePickHint'
| 'noTablesAvailable'
| 'chooseLanguage'
| 'startOrdering'
| 'voiceOrder'
| 'staffLogin'
| 'menu'
| 'cart'
| 'yourOrder'
| 'placeOrder'
| 'orderStatus'
| 'payBill'
| 'addToCart'
| 'back'
| 'loading'
| 'offlineHint'
| 'reconnectKds'
| 'kdsLive'
| 'logout';
const en: Record<MessageKey, string> = {
brand: 'RestroAI',
welcome: 'Welcome to your table',
tableLabel: 'Table',
selectTable: 'Select table',
tableLockedHint: 'Table locked from QR scan',
tablePickHint: 'Tap to choose your table',
noTablesAvailable: 'No free tables right now — ask staff',
chooseLanguage: 'Choose language',
startOrdering: 'Start ordering',
voiceOrder: 'Order with voice',
staffLogin: 'Staff login',
menu: 'Menu',
cart: 'Cart',
yourOrder: 'Your order',
placeOrder: 'Place order',
orderStatus: 'Order status',
payBill: 'Pay bill',
addToCart: 'Add to cart',
back: 'Back',
loading: 'Loading…',
offlineHint: 'You are offline — showing cached menu when available.',
reconnectKds: 'Reconnecting to kitchen…',
kdsLive: 'Kitchen live',
logout: 'Log out',
};
const hi: Record<MessageKey, string> = {
brand: 'RestroAI',
welcome: 'आपकी मेज़ पर स्वागत है',
tableLabel: 'टेबल',
selectTable: 'टेबल चुनें',
tableLockedHint: 'QR स्कैन से टेबल लॉक है',
tablePickHint: 'अपनी टेबल चुनने के लिए टैप करें',
noTablesAvailable: 'अभी कोई खाली टेबल नहीं — स्टाफ़ से पूछें',
chooseLanguage: 'भाषा चुनें',
startOrdering: 'ऑर्डर शुरू करें',
voiceOrder: 'आवाज़ से ऑर्डर',
staffLogin: 'स्टाफ़ लॉगिन',
menu: 'मेनू',
cart: 'कार्ट',
yourOrder: 'आपका ऑर्डर',
placeOrder: 'ऑर्डर भेजें',
orderStatus: 'ऑर्डर स्थिति',
payBill: 'बिल भुगतान',
addToCart: 'कार्ट में डालें',
back: 'वापस',
loading: 'लोड हो रहा है…',
offlineHint: 'आप ऑफ़लाइन हैं — कैश मेनू दिखाया जा रहा है।',
reconnectKds: 'किचन से फिर जुड़ रहे हैं…',
kdsLive: 'किचन लाइव',
logout: 'लॉग आउट',
};
/** Hinglish: Hindi structure with English restaurant terms. */
const hinglish: Record<MessageKey, string> = {
brand: 'RestroAI',
welcome: 'Aapki table par swagat hai',
tableLabel: 'Table',
selectTable: 'Table select karein',
tableLockedHint: 'QR scan se table lock hai',
tablePickHint: 'Apni table choose karne ke liye tap karein',
noTablesAvailable: 'Abhi free table nahi — staff se poochhein',
chooseLanguage: 'Language choose karein',
startOrdering: 'Ordering start karein',
voiceOrder: 'Voice se order',
staffLogin: 'Staff login',
menu: 'Menu',
cart: 'Cart',
yourOrder: 'Aapka order',
placeOrder: 'Order place karein',
orderStatus: 'Order status',
payBill: 'Bill pay karein',
addToCart: 'Cart mein add',
back: 'Back',
loading: 'Loading…',
offlineHint: 'Aap offline ho — cached menu dikha rahe hain.',
reconnectKds: 'Kitchen se reconnect ho raha hai…',
kdsLive: 'Kitchen live',
logout: 'Log out',
};
export const messages: Record<AppLocale, Record<MessageKey, string>> = {
en,
hi,
hinglish,
};
export function toAppLocale(raw: string | null | undefined): AppLocale {
const v = (raw || 'en').toLowerCase();
if (v === 'hi' || v === 'hindi') return 'hi';
if (v === 'hinglish') return 'hinglish';
return 'en';
}
export function toSessionLanguage(locale: AppLocale): 'en' | 'hi' | 'hinglish' {
if (locale === 'hi') return 'hi';
if (locale === 'hinglish') return 'hinglish';
return 'en';
}
export function uiLangToLocale(ui: 'english' | 'hindi' | 'hinglish'): AppLocale {
if (ui === 'hindi') return 'hi';
if (ui === 'hinglish') return 'hinglish';
return 'en';
}

View File

@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { CustomerApp } from './apps/customer/App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<CustomerApp />
</StrictMode>,
)
);

View File

@ -1,44 +1,146 @@
import { type MenuItem, MENU_ITEMS } from '../data/menuData';
import { type MenuItem, MENU_ITEMS, CATEGORIES } from '../data/menuData';
import type { CategoryType } from '../data/menuData';
import { debugLog, maskToken } from '../utils/debugLog';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
const DEMO_FALLBACK = import.meta.env.VITE_DEMO_FALLBACK === 'true';
export type PublicMenuItem = {
id: number;
name: string;
name_hindi?: string | null;
description?: string | null;
price: number | string;
prep_time_minutes?: number | null;
dietary_type?: string | null;
image_url?: string | null;
spice_level?: string | null;
is_available: boolean;
is_chef_special: boolean;
is_bestseller: boolean;
tags: string[];
};
export type PublicMenuCategory = {
id: number;
name: string;
display_order: number;
items: PublicMenuItem[];
};
export type PublicMenuResponse = {
restaurant_id: number;
updated_at?: string | null;
categories: PublicMenuCategory[];
};
export type StaffMenuItem = {
id: number;
restaurant_id: number;
category_id: number | null;
category_name?: string | null;
name: string;
name_hindi?: string | null;
description?: string | null;
price: number | string;
gst_rate: number | string;
prep_time_minutes?: number | null;
dietary_type?: string | null;
image_url?: string | null;
spice_level?: string | null;
is_available: boolean;
is_chef_special: boolean;
is_bestseller: boolean;
tags: { id: number; tag_type: string; value: string }[];
};
export type StaffMenuCategory = {
id: number;
restaurant_id: number;
name: string;
display_order: number;
};
const toUiDietary = (value?: string | null): MenuItem['dietary'] => {
if (value === 'non_veg' || value === 'non-veg') return 'non-veg';
if (value === 'vegan' || value === 'jain' || value === 'veg') return value;
return 'veg';
};
export const flattenPublicMenu = (catalog: PublicMenuResponse): MenuItem[] => {
return catalog.categories.flatMap((category) =>
category.items.map((item) => ({
id: item.id,
name: item.name,
nameHindi: item.name_hindi || undefined,
category: (CATEGORIES.includes(category.name as CategoryType)
? category.name
: 'Specials') as MenuItem['category'],
price: Number(item.price),
prepTimeMinutes: item.prep_time_minutes ?? 10,
dietary: toUiDietary(item.dietary_type),
isChefSpecial: item.is_chef_special,
isBestseller: item.is_bestseller,
description: item.description || '',
image: item.image_url || '',
spiceLevel: (item.spice_level as MenuItem['spiceLevel']) || undefined,
tags: item.tags || [],
isAvailable: item.is_available,
}))
);
};
// Helpers to get/set auth tokens
const getAccessToken = () => localStorage.getItem('access_token');
export { getAccessToken };
export const getRefreshToken = () => localStorage.getItem('refresh_token');
const setTokens = (access: string, refresh: string) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
};
const clearTokens = () => {
export const clearTokens = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
};
const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id');
const setCustomerSession = (token: string, sessionId: string, tableId: string, restaurantId: string) => {
const setCustomerSession = (
token: string,
sessionId: string,
tableId: string,
restaurantId: string,
tableNumber?: string,
) => {
sessionStorage.setItem('customer_session_token', token);
sessionStorage.setItem('customer_session_id', sessionId);
sessionStorage.setItem('customer_table_id', tableId);
sessionStorage.setItem('customer_restaurant_id', restaurantId);
if (tableNumber) {
sessionStorage.setItem('customer_table_number', tableNumber);
}
};
export const clearCustomerSession = () => {
sessionStorage.removeItem('customer_session_token');
sessionStorage.removeItem('customer_session_id');
sessionStorage.removeItem('customer_table_id');
sessionStorage.removeItem('customer_restaurant_id');
sessionStorage.removeItem('customer_table_number');
};
// Map front-end item ID (e.g. 'm1') to back-end numeric ID
export const getBackendMenuId = (frontendId: string): number => {
return parseInt(frontendId.replace(/[^0-9]/g, '')) || 1;
/** @deprecated Prefer numeric catalog ids from GET /menu/public */
export const getBackendMenuId = (frontendId: string | number): number => {
if (typeof frontendId === 'number') return frontendId;
return parseInt(String(frontendId).replace(/[^0-9]/g, ''), 10) || 1;
};
// Map back-end ID to front-end menu item
export const getFrontendMenuItem = (backendId: number | string): MenuItem | undefined => {
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId)) || 1;
return MENU_ITEMS.find(m => m.id === `m${numId}`) || MENU_ITEMS[0];
export const getFrontendMenuItem = (
backendId: number | string,
catalog: MenuItem[] = MENU_ITEMS,
): MenuItem | undefined => {
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId), 10) || 1;
return catalog.find((m) => m.id === numId) || catalog[0];
};
// Connection State tracking
@ -107,6 +209,13 @@ const mockDb = {
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
const url = `${BASE_URL}${path}`;
const headers = new Headers(options.headers || {});
const method = (options.method || 'GET').toUpperCase();
const isAiOrSession =
path.startsWith('/ai') ||
path.includes('/start-session') ||
path.startsWith('/tables/public') ||
path.startsWith('/menu/public');
const started = performance.now();
// Inject Staff Auth Token
const staffToken = getAccessToken();
@ -124,13 +233,27 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
// Let the browser set multipart boundary for FormData
if (options.body instanceof FormData && headers.has('Content-Type')) {
headers.delete('Content-Type');
}
if (isAiOrSession) {
debugLog.info('api', `${method} ${path}`, {
hasSession: Boolean(customerToken),
sessionToken: maskToken(customerToken),
hasStaff: Boolean(staffToken),
bodyPreview:
typeof options.body === 'string' ? options.body.slice(0, 180) : options.body ? '(FormData)' : undefined,
});
}
try {
const res = await fetch(url, { ...options, headers });
triggerDemoMode(false);
if (res.status === 401 && staffToken) {
// Token expired, clear it
if (res.status === 401 && staffToken && !customerToken) {
// Only clear staff auth when this was a staff-only request.
clearTokens();
window.location.reload();
throw new Error('Staff session expired. Please log in again.');
@ -144,11 +267,49 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
} catch {
parsedErr = { error: errText };
}
throw new Error(parsedErr.error || parsedErr.message || `Request failed: ${res.status}`);
const detail = parsedErr.detail;
const detailText =
typeof detail === 'string'
? detail
: Array.isArray(detail)
? detail.map((d: { msg?: string }) => d.msg || JSON.stringify(d)).join('; ')
: detail
? JSON.stringify(detail)
: '';
const message =
detailText ||
parsedErr.error ||
parsedErr.message ||
`Request failed: ${res.status}`;
if (isAiOrSession) {
debugLog.error('api', `${method} ${path} failed`, {
status: res.status,
ms: Math.round(performance.now() - started),
detail: String(message).slice(0, 300),
});
}
throw new Error(message);
}
return await res.json();
const json = await res.json();
if (isAiOrSession) {
debugLog.info('api', `${method} ${path} ok`, {
status: res.status,
ms: Math.round(performance.now() - started),
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
});
}
return json;
} catch (error) {
if (isAiOrSession) {
debugLog.error('api', `${method} ${path} exception`, {
ms: Math.round(performance.now() - started),
error: error instanceof Error ? error.message : String(error),
});
}
if (!DEMO_FALLBACK) {
throw error instanceof Error ? error : new Error(String(error));
}
console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
triggerDemoMode(true);
return handleMockRequest(path, options);
@ -317,9 +478,47 @@ function handleMockRequest(path: string, options: RequestInit): any {
return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
}
// 7. KDS Board
// 7. KDS Board + staff test order
if (path.startsWith('/kds/')) {
return mockDb.orders;
const parts = path.split('/').filter(Boolean);
// /kds/{id}/test-order
if (parts.length >= 3 && parts[2] === 'test-order' && method === 'POST') {
const newOrder = {
id: `ord-mock-${Date.now()}`,
order_id: Date.now(),
ticketNumber: String(100 + mockDb.orders.length + 1),
table_number: 'T1',
channel: 'manual_dine_in',
orderType: 'Dine-In',
status: 'placed',
placed_at: new Date().toISOString(),
createdAt: new Date().toISOString(),
timeElapsedMinutes: 0,
items: [
{
id: `oi-${Date.now()}-1`,
menu_item_id: 9,
name: 'Butter Chicken',
quantity: 1,
unit_price: 380,
kds_status: 'queued',
},
{
id: `oi-${Date.now()}-2`,
menu_item_id: 16,
name: 'Garlic Naan',
quantity: 2,
unit_price: 60,
kds_status: 'queued',
},
],
};
mockDb.orders.push(newOrder);
notifyWsListeners({ type: 'kds.item_updated' });
return newOrder;
}
// /kds/{id}/board
return { restaurant_id: Number(parts[1]) || 1, orders: mockDb.orders };
}
// Patch KDS order item status
@ -555,11 +754,16 @@ const notifyWsListeners = (event: any) => {
});
};
export type WsConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
export class RestroWebSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectTimer: any = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private isClosedIntentional = false;
private hadOpenConnection = false;
onStateChange: ((state: WsConnectionState) => void) | null = null;
onReconnected: (() => void) | null = null;
constructor(channel: 'kds' | 'orders', param: string) {
if (channel === 'kds') {
@ -571,15 +775,30 @@ export class RestroWebSocket {
}
}
private setState(state: WsConnectionState) {
this.onStateChange?.(state);
}
connect() {
if (isDemoMode) {
console.log('Skipping real WebSocket connection in Demo Mode');
this.setState('open');
return;
}
this.isClosedIntentional = false;
this.setState(this.hadOpenConnection ? 'reconnecting' : 'connecting');
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
const reconnected = this.hadOpenConnection;
this.hadOpenConnection = true;
this.setState('open');
if (reconnected) {
this.onReconnected?.();
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
@ -592,7 +811,10 @@ export class RestroWebSocket {
this.ws.onclose = () => {
if (!this.isClosedIntentional) {
console.log('WebSocket closed. Attempting reconnect in 5s...');
this.setState('reconnecting');
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
} else {
this.setState('closed');
}
};
@ -602,6 +824,8 @@ export class RestroWebSocket {
};
} catch (e) {
console.error('WebSocket connection setup failed', e);
this.setState('reconnecting');
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
}
}
@ -612,6 +836,7 @@ export class RestroWebSocket {
this.ws.close();
this.ws = null;
}
this.setState('closed');
}
}
@ -646,19 +871,143 @@ export const api = {
return !!getAccessToken();
},
getMe: async (): Promise<{
id: number;
email: string;
full_name: string;
restaurant_id: number;
home_restaurant_id?: number;
role: string;
is_active: boolean;
restaurants?: { id: number; name: string; is_home: boolean; is_active: boolean }[];
}> => {
return apiRequest('/auth/me');
},
switchRestaurant: async (restaurantId: number) => {
const res = await apiRequest('/auth/switch-restaurant', {
method: 'POST',
body: JSON.stringify({ restaurant_id: restaurantId }),
});
if (res.access_token) {
setTokens(res.access_token, res.refresh_token);
}
return res;
},
aiChatTurn: async (payload: {
transcript: string;
language?: string;
submit?: boolean;
}): Promise<{
transcript: string;
assistant_text: string;
cart: {
menu_item_id: number;
name: string;
quantity: number;
unit_price: number | string;
notes: string[];
is_available: boolean;
}[];
order: { order_id: number; subtotal: number | string; channel: string; item_count: number } | null;
tool_calls: string[];
}> => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 25_000);
try {
return await apiRequest('/ai/chat-turn', {
method: 'POST',
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('AI is taking too long — please try again in a moment.');
}
throw err;
} finally {
window.clearTimeout(timer);
}
},
/** Staff JWT: inventory / kitchen assistant. */
staffAiChatTurn: async (payload: {
transcript: string;
language?: string;
}): Promise<{
transcript: string;
assistant_text: string;
tool_calls: string[];
low_stock: {
id: number;
name: string;
unit: string;
current_stock: number;
reorder_threshold: number;
is_low: boolean;
}[];
updates?: {
id: number;
name: string;
unit: string;
current_stock: number;
reorder_threshold: number;
is_low: boolean;
}[];
notify?: Record<string, unknown> | null;
}> => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 25_000);
try {
return await apiRequest('/ai/staff-chat-turn', {
method: 'POST',
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Staff AI is taking too long — please try again.');
}
throw err;
} finally {
window.clearTimeout(timer);
}
},
// Tables & Session
startSession: async (tableId: number, language = 'en') => {
startSession: async (tableId: number, language = 'en', tableNumber?: string) => {
const res = await apiRequest(`/tables/${tableId}/start-session`, {
method: 'POST',
body: JSON.stringify({ language })
});
if (res.session_token) {
setCustomerSession(res.session_token, String(res.session_id), String(res.table_id), String(res.restaurant_id));
setCustomerSession(
res.session_token,
String(res.session_id),
String(res.table_id),
String(res.restaurant_id),
tableNumber,
);
}
return res;
},
/** Guest table picker (no auth). Defaults to first restaurant. */
getPublicTables: async (opts?: {
restaurantId?: number;
availableOnly?: boolean;
}): Promise<
{ id: number; restaurant_id: number; table_number: string; capacity: number; status: string }[]
> => {
const params = new URLSearchParams();
if (opts?.restaurantId != null) params.set('restaurant_id', String(opts.restaurantId));
if (opts?.availableOnly === false) params.set('available_only', 'false');
const qs = params.toString() ? `?${params}` : '';
return apiRequest(`/tables/public${qs}`);
},
closeSession: async (tableId: number) => {
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' });
},
@ -694,8 +1043,17 @@ export const api = {
});
},
/** Staff KDS: create a sample ticket without a guest session. */
createKdsTestOrder: async (restaurantId: string | number) => {
return apiRequest(`/kds/${restaurantId}/test-order`, { method: 'POST' });
},
getOrderForSession: async (sessionId: string) => {
return apiRequest(`/orders/${sessionId}`);
const data = await apiRequest(`/orders/${sessionId}`);
if (Array.isArray(data)) {
return data.length ? data[data.length - 1] : null;
}
return data;
},
getStaffOrderForSession: async (sessionId: string) => {
@ -718,10 +1076,39 @@ export const api = {
},
// Billing
generateBill: async (orderId: string) => {
generateBill: async (orderId: string | number) => {
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' });
},
/** Customer session: generate or return existing bill for an order. */
sessionGenerateBill: async (orderId: string | number) => {
return apiRequest(`/session/orders/${orderId}/generate-bill`, { method: 'POST' });
},
sessionGetBill: async (billId: string | number) => {
return apiRequest(`/session/bills/${billId}`);
},
sessionCheckoutRazorpay: async (billId: string | number) => {
return apiRequest(`/session/bills/${billId}/checkout/razorpay`, { method: 'POST' });
},
sessionConfirmRazorpay: async (
billId: string | number,
payload: {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
amount?: number;
method?: string;
}
) => {
return apiRequest(`/session/bills/${billId}/confirm-razorpay`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
recordPayment: async (billId: string, method: string, amount: number) => {
return apiRequest(`/bills/${billId}/record-payment`, {
method: 'POST',
@ -766,6 +1153,10 @@ export const api = {
return apiRequest('/inventory/low-stock');
},
notifyLowStock: async () => {
return apiRequest('/inventory/notify-low-stock', { method: 'POST' });
},
// Recipes
getRecipe: async (menuItemId: number) => {
return apiRequest(`/menu-items/${menuItemId}/recipe`);
@ -779,6 +1170,65 @@ export const api = {
});
},
// Menu catalog
getPublicMenu: async (): Promise<PublicMenuResponse> => {
const known =
typeof localStorage !== 'undefined'
? localStorage.getItem('menu_catalog_updated_at') || ''
: '';
const qs = known ? `?updated_at=${encodeURIComponent(known)}` : '';
const data = (await apiRequest(`/menu/public${qs}`)) as PublicMenuResponse;
if (data?.updated_at && typeof localStorage !== 'undefined') {
localStorage.setItem('menu_catalog_updated_at', data.updated_at);
}
return data;
},
listMenuCategories: async (): Promise<StaffMenuCategory[]> => {
return apiRequest('/menu/categories');
},
createMenuCategory: async (payload: { name: string; display_order?: number }) => {
return apiRequest('/menu/categories', {
method: 'POST',
body: JSON.stringify(payload),
});
},
updateMenuCategory: async (id: number, payload: { name?: string; display_order?: number }) => {
return apiRequest(`/menu/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
},
deleteMenuCategory: async (id: number) => {
return apiRequest(`/menu/categories/${id}`, { method: 'DELETE' });
},
listMenuItems: async (categoryId?: number): Promise<StaffMenuItem[]> => {
const qs = categoryId != null ? `?category_id=${categoryId}` : '';
return apiRequest(`/menu/items${qs}`);
},
createMenuItem: async (payload: Record<string, unknown>): Promise<StaffMenuItem> => {
return apiRequest('/menu/items', {
method: 'POST',
body: JSON.stringify(payload),
});
},
updateMenuItem: async (id: number, payload: Record<string, unknown>): Promise<StaffMenuItem> => {
return apiRequest(`/menu/items/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
},
deleteMenuItem: async (id: number) => {
return apiRequest(`/menu/items/${id}`, { method: 'DELETE' });
},
// Suppliers CRUD
getSuppliers: async () => {
return apiRequest('/suppliers');

View File

@ -1,145 +1,2 @@
import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#ac2d00',
light: '#ffb5a0',
dark: '#872100',
contrastText: '#ffffff',
},
secondary: {
main: '#546067',
light: '#818e95',
dark: '#2a363d',
contrastText: '#ffffff',
},
background: {
default: '#f8f9fa',
paper: '#ffffff',
},
error: {
main: '#ba1a1a',
light: '#ffdad6',
dark: '#93000a',
},
warning: {
main: '#845000',
light: '#ffddba',
dark: '#2b1700',
},
success: {
main: '#11651d',
light: '#a3f69c',
dark: '#003915',
},
info: {
main: '#00a6e0',
light: '#c4e7ff',
dark: '#00374d',
},
text: {
primary: '#1a1c1c',
secondary: '#5b4139',
},
divider: '#e4beb4',
},
typography: {
fontFamily: '"Inter", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 800,
letterSpacing: '-0.02em',
},
h2: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
letterSpacing: '-0.01em',
},
h3: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
},
h4: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 600,
},
h5: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
h6: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
subtitle1: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
body1: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.5,
},
body2: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.43,
},
button: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
textTransform: 'none',
},
caption: {
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
padding: '8px 16px',
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 8px rgba(172, 45, 0, 0.25)',
},
},
contained: {
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0px 2px 12px rgba(0, 0, 0, 0.05)',
border: '1px solid rgba(228, 190, 180, 0.4)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 600,
borderRadius: 6,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#ffffff',
color: '#1a1c1c',
boxShadow: '0px 1px 10px rgba(0,0,0,0.05)',
borderBottom: '1px solid #e2e2e2',
},
},
},
},
});
/** Re-export shared theme from @restroai/ui. */
export { theme } from '@restroai/ui';

46
src/utils/debugLog.ts Normal file
View File

@ -0,0 +1,46 @@
/**
* Browser debug logger for RestroAI guest/AI flows.
* Enable with localStorage.setItem('restroai_debug', '1') or ?debug=1
* Always logs warn/error; info/debug only when enabled.
*/
const PREFIX = '[RestroAI]';
function debugEnabled(): boolean {
if (typeof window === 'undefined') return false;
try {
if (localStorage.getItem('restroai_debug') === '1') return true;
if (new URLSearchParams(window.location.search).get('debug') === '1') return true;
} catch {
/* ignore */
}
// Default ON in Vite dev so Chrome DevTools always shows AI/session traces.
return Boolean(import.meta.env.DEV);
}
type LogPayload = Record<string, unknown> | undefined;
function fmt(scope: string, message: string, payload?: LogPayload): unknown[] {
const ts = new Date().toISOString().slice(11, 23);
if (payload === undefined) return [`${PREFIX} ${ts} ${scope} ${message}`];
return [`${PREFIX} ${ts} ${scope} ${message}`, payload];
}
export const debugLog = {
enabled: debugEnabled,
info(scope: string, message: string, payload?: LogPayload) {
if (!debugEnabled()) return;
console.info(...fmt(scope, message, payload));
},
warn(scope: string, message: string, payload?: LogPayload) {
console.warn(...fmt(scope, message, payload));
},
error(scope: string, message: string, payload?: LogPayload) {
console.error(...fmt(scope, message, payload));
},
};
export function maskToken(token: string | null | undefined): string {
if (!token) return '(none)';
if (token.length <= 12) return `${token.slice(0, 4)}`;
return `${token.slice(0, 6)}${token.slice(-4)} (len=${token.length})`;
}

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

18
staff.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1c1c" />
<meta name="robots" content="noindex" />
<title>RestroAI Staff Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/apps/staff/main.tsx"></script>
</body>
</html>

View File

@ -4,11 +4,16 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@restroai/ui": ["packages/ui/src/index.ts"],
"@restroai/ui/*": ["packages/ui/src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
@ -16,11 +21,10 @@
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src", "packages/ui/src"]
}

View File

@ -1,7 +1,86 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'node:path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons.svg'],
manifest: {
name: 'RestroAI Guest',
short_name: 'RestroAI',
description: 'Scan, order, and pay at your table',
theme_color: '#ac2d00',
background_color: '#fff9f7',
display: 'standalone',
start_url: '/',
scope: '/',
icons: [
{
src: '/icons.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/staff\.html/, /^\/api/],
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.includes('/menu/public'),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'restroai-menu',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'restroai-images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 60 * 60 * 24 * 7,
},
},
},
],
},
// Dev uses Vite HMR; SW/precache only in production builds (avoids empty dev-dist warning).
devOptions: {
enabled: false,
},
}),
],
resolve: {
alias: {
'@restroai/ui': path.resolve(__dirname, 'packages/ui/src'),
},
},
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
staff: path.resolve(__dirname, 'staff.html'),
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
},
});