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 # RestroAI Frontend Environment Variables
# The HTTP URL of the remote backend server # Local full stack (docker compose up from retro_backend repo)
VITE_API_URL=https://api.yourdomain.com VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
# The WebSocket URL of the remote backend server # Production
VITE_WS_URL=wss://api.yourdomain.com # 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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>RestroAI Operations Platform - Kitchen & Analytics Suite</title> <meta name="theme-color" content="#ac2d00" />
<!-- Google Fonts: Inter, Plus Jakarta Sans & JetBrains Mono --> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/apps/customer/main.tsx"></script>
</body> </body>
</html> </html>

4813
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:customer": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview"
}, },
@ -16,7 +17,10 @@
"@mui/icons-material": "^9.2.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0", "@mui/material": "^9.2.0",
"react": "^19.2.7", "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": { "devDependencies": {
"@types/node": "^24.13.2", "@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'; /** @deprecated Use CustomerApp from src/apps/customer — kept for tooling that imports ./App */
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material'; export { CustomerApp as default, CustomerApp as App } from './apps/customer/App';
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;

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

View File

@ -16,6 +16,7 @@ import {
MenuItem, MenuItem,
Button, Button,
Grid, Grid,
Chip,
} from '@mui/material'; } from '@mui/material';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
@ -133,9 +134,30 @@ export const FeedbackInboxView: React.FC = () => {
</Box> </Box>
} }
secondary={ secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}> <Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
{fb.comment_text || '(No comment left by guest)'} {fb.comment_text || '(No comment left by guest)'}
</Typography> </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> </ListItem>

View File

@ -36,10 +36,11 @@ import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { MENU_ITEMS } from '../../data/menuData'; import type { StaffMenuItem } from '../../services/api';
export const InventoryView: React.FC = () => { export const InventoryView: React.FC = () => {
const [ingredients, setIngredients] = useState<any[]>([]); const [ingredients, setIngredients] = useState<any[]>([]);
const [menuDishes, setMenuDishes] = useState<StaffMenuItem[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -65,8 +66,16 @@ export const InventoryView: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await api.getIngredients(); const [data, dishes] = await Promise.all([
api.getIngredients(),
api.listMenuItems().catch(() => []),
]);
setIngredients(Array.isArray(data) ? data : []); 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) { } catch (err: any) {
setError(err.message || 'Failed to fetch ingredients'); setError(err.message || 'Failed to fetch ingredients');
} finally { } finally {
@ -271,9 +280,9 @@ export const InventoryView: React.FC = () => {
label="Select Menu Dish" label="Select Menu Dish"
onChange={(e) => setSelectedMenuId(Number(e.target.value))} onChange={(e) => setSelectedMenuId(Number(e.target.value))}
> >
{MENU_ITEMS.map((item) => ( {menuDishes.map((item) => (
<MenuItem key={item.id} value={parseInt(item.id.replace('m', ''))}> <MenuItem key={item.id} value={item.id}>
{item.name} ({item.price}) {item.name} ({Number(item.price)})
</MenuItem> </MenuItem>
))} ))}
</Select> </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, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import BadgeIcon from '@mui/icons-material/Badge';
import { api } from '../../services/api'; import { api } from '../../services/api';
export type StaffRole = 'chef' | 'waiter' | 'cashier' | 'manager';
interface StaffLoginModalProps { interface StaffLoginModalProps {
open: boolean; open: boolean;
onClose: () => void; 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> = ({ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
open, open,
onClose, onClose,
@ -43,10 +52,8 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
setError(''); setError('');
try { try {
await api.login(username, password); await api.login(username, password);
// Determine role based on JWT claims or username const me = await api.getMe();
const role = username.includes('manager') ? 'manager' : 'kitchen'; onLoginSuccess(normalizeRole(me.role));
onLoginSuccess(role);
onClose();
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Login failed. Please check your credentials.'); setError(err.message || 'Login failed. Please check your credentials.');
} finally { } finally {
@ -81,7 +88,7 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
Staff & Admin Portal Login Staff & Admin Portal Login
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#5b4139' }}> <Typography variant="caption" sx={{ color: '#5b4139' }}>
Authorized Restaurant Personnel Only Role comes from JWT via /auth/me
</Typography> </Typography>
</DialogTitle> </DialogTitle>
@ -97,70 +104,62 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}> <Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
QUICK AUTOFILL DEMO LOGIN: QUICK AUTOFILL DEMO LOGIN:
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip <Chip
icon={<BadgeIcon fontSize="small" />} label="Manager"
label="Manager Account"
clickable clickable
onClick={() => fillCredentials('manager@test.com', 'managerpass')} onClick={() => fillCredentials('manager@demo.restro', 'Manager@12345')}
sx={{ fontWeight: 700, flex: 1 }} sx={{ fontWeight: 700 }}
/> />
<Chip <Chip
icon={<BadgeIcon fontSize="small" />} label="Chef"
label="Chef / Waiter Account"
clickable clickable
onClick={() => fillCredentials('waiter@test.com', 'waiterpass')} onClick={() => fillCredentials('chef@demo.restro', 'Chef@12345')}
sx={{ fontWeight: 700, flex: 1 }} 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>
</Box> </Box>
<TextField <TextField
label="Email Address / Username" label="Email"
type="email"
placeholder="manager@test.com"
fullWidth fullWidth
margin="dense"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
sx={{ mb: 2 }} autoComplete="username"
disabled={loading}
/> />
<TextField <TextField
label="Password" label="Password"
type="password" type="password"
placeholder="••••••••"
fullWidth fullWidth
margin="dense"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
sx={{ mb: 2.5 }} autoComplete="current-password"
disabled={loading}
/> />
<Button <DialogActions sx={{ px: 0, pt: 2 }}>
type="submit" <Button onClick={onClose} disabled={loading}>
fullWidth Cancel
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'}
</Button> </Button>
</form> <Button type="submit" variant="contained" disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>
</DialogContent> {loading ? 'Signing in…' : 'Sign In'}
<DialogActions sx={{ p: 2, justifyContent: 'center' }}>
<Button size="small" onClick={onClose} color="inherit" disabled={loading}>
Cancel & Return to Dining
</Button> </Button>
</DialogActions> </DialogActions>
</form>
</DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

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

View File

@ -127,7 +127,16 @@ export const SuppliersView: React.FC = () => {
return; return;
} }
// Backend schema: { ingredient_id, quantity, unit_price } // 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(''); setAddIngId('');
}; };

View File

@ -1,102 +1,2 @@
import React from 'react'; /** Re-export shared badges from @restroai/ui. */
import { Chip, Box, Typography } from '@mui/material'; export { StatusBadge, DietaryBadge } from '@restroai/ui';
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;
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Container, Container,
@ -9,81 +9,239 @@ import {
Divider, Divider,
Alert, Alert,
Grid, Grid,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorOutlineIcon from '@mui/icons-material/WarningAmber';
import QrCode2Icon from '@mui/icons-material/QrCode2'; import QrCode2Icon from '@mui/icons-material/QrCode2';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import CreditCardIcon from '@mui/icons-material/CreditCard'; import CreditCardIcon from '@mui/icons-material/CreditCard';
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee'; import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
import { api } from '../../services/api'; import { api, getFrontendMenuItem } from '../../services/api';
interface BillPaymentViewProps { interface BillPaymentViewProps {
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void; 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 }) => { export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
const [splitCount, setSplitCount] = useState<number>(1); const [splitCount, setSplitCount] = useState<number>(1);
const [tipPercentage, setTipPercentage] = useState<number>(10); 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 [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 [ticketNum, setTicketNum] = useState<string>('---');
const [tableNum, setTableNum] = useState<string>('12'); 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 { 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); const order = await api.getOrderForSession(sessionId);
if (order) { if (!order) {
setTicketNum(order.ticketNumber || '105'); setError('No order found for this table yet.');
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12'); return;
}
setBillItems(order.items.map((it: any) => ({ setTicketNum(String(order.id ?? '---'));
name: it.name, setTableNum(sessionStorage.getItem('customer_table_id') || '12');
qty: it.quantity,
price: it.price || 150
})));
const bill = await api.generateBill(order.id); const items = (order.items || []).map((it: any) => {
if (bill) { const menu = getFrontendMenuItem?.(it.menu_item_id);
setBillId(bill.id); return {
} name: it.name || menu?.name || `Dish ${it.menu_item_id}`,
} qty: Number(it.quantity) || 1,
} catch (e) { price: Number(it.unit_price ?? it.price ?? menu?.price ?? 0),
console.error('Failed to load bill', e);
}
}; };
});
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(() => { useEffect(() => {
loadBill(); loadBill();
}, []); }, [loadBill]);
const subtotal = billItems.reduce((acc, i) => acc + i.price * i.qty, 0); const tip = billSubtotal * (tipPercentage / 100);
const cgst = subtotal * 0.025; const displayTotal = billTotal + tip;
const sgst = subtotal * 0.025; const perPersonPrice = displayTotal / splitCount;
const serviceCharge = subtotal * 0.05;
const tip = subtotal * (tipPercentage / 100);
const totalBill = subtotal + cgst + sgst + serviceCharge + tip;
const perPersonPrice = totalBill / splitCount;
const handlePay = async (method: 'upi' | 'card' | 'cash') => { const confirmMockCheckout = async (
try { checkout: any,
if (billId) { method: 'upi' | 'card'
await api.recordPayment(billId, method, totalBill); ) => {
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); const amountPaise = Math.round(Number(checkout.amount) * 100);
setPaymentDone(true); const rzp = new window.Razorpay({
} catch (e) { key: checkout.key_id,
alert('Failed to record payment: ' + e); 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 ( return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}> <Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box <Box
sx={{ sx={{
position: 'sticky', position: 'sticky',
@ -104,19 +262,26 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Bill & Payment Bill & Payment
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}> <Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {tableNum} · Ticket #{ticketNum} Table {tableNum} · Order #{ticketNum}
{billId ? ` · Bill #${billId}` : ''}
</Typography> </Typography>
</Box> </Box>
<Chip <Chip
icon={<ReceiptLongIcon fontSize="small" />} icon={<ReceiptLongIcon fontSize="small" />}
label={paymentDone ? 'PAID ✓' : 'UNPAID'} label={outcome === 'success' ? 'PAID ✓' : outcome === 'failure' ? 'FAILED' : 'UNPAID'}
color={paymentDone ? 'success' : 'error'} color={outcome === 'success' ? 'success' : outcome === 'failure' ? 'warning' : 'error'}
sx={{ fontWeight: 800 }} sx={{ fontWeight: 800 }}
/> />
</Box> </Box>
<Container maxWidth="sm" sx={{ pt: 3 }}> <Container maxWidth="sm" sx={{ pt: 3 }}>
{paymentDone ? ( {error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: '12px' }}>
{error}
</Alert>
)}
{outcome === 'success' ? (
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
@ -132,10 +297,13 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Payment Successful! Payment Successful!
</Typography> </Typography>
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}> <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>
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}> <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> </Typography>
<Button <Button
variant="contained" variant="contained"
@ -147,16 +315,61 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
Back to Home Back to Home
</Button> </Button>
</Paper> </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
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}> elevation={0}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> 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' }}> <Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
🧾 Bill Details Bill Details
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontFamily: 'monospace', fontWeight: 700 }}>
GSTIN: 29XXXXXX0001Z5
</Typography> </Typography>
</Box> </Box>
@ -174,32 +387,48 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
{[ {[
{ label: 'Subtotal', value: `${subtotal.toFixed(0)}` }, { label: 'Subtotal', value: `${billSubtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` }, { label: 'CGST', value: `${cgstAmount.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.toFixed(0)}` }, { label: 'SGST', value: `${sgstAmount.toFixed(0)}` },
{ label: 'Service Charge (5%)', value: `${serviceCharge.toFixed(0)}` }, { label: `Suggested tip (${tipPercentage}%)`, value: `${tip.toFixed(0)}` },
{ label: `Staff Tip (${tipPercentage}%)`, value: `${tip.toFixed(0)}` },
].map((row) => ( ].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}> <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={{ color: '#6b5c57' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography> {row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.value}
</Typography>
</Box> </Box>
))} ))}
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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' }}> <Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{totalBill.toFixed(0)} {billTotal.toFixed(0)}
</Typography> </Typography>
</Box> </Box>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
Gateway charges bill total (tip is optional / cash).
</Typography>
</Paper> </Paper>
{/* Split Bill */} <Paper
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}> 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 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
👥 Split the Bill Split the Bill
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
{[1, 2, 3, 4].map((count) => ( {[1, 2, 3, 4].map((count) => (
@ -217,15 +446,23 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
</Box> </Box>
{splitCount > 1 && ( {splitCount > 1 && (
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}> <Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
Each person pays: {perPersonPrice.toFixed(0)} Each person {perPersonPrice.toFixed(0)} (incl. suggested tip)
</Alert> </Alert>
)} )}
</Paper> </Paper>
{/* Tip Selector */} <Paper
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}> 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 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
Add Tip for Staff Suggested tip
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
{[0, 10, 15, 20].map((pct) => ( {[0, 10, 15, 20].map((pct) => (
@ -242,19 +479,33 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
</Box> </Box>
</Paper> </Paper>
{/* Payment Options */} {cashHint && (
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#8f7068', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}> <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 Choose Payment Method
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* UPI Button */}
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
size="large" size="large"
onClick={() => handlePay('upi')} disabled={paying || !billId}
startIcon={<QrCode2Icon />} onClick={() => handleGatewayPay('upi')}
startIcon={paying ? <CircularProgress size={18} color="inherit" /> : <QrCode2Icon />}
sx={{ sx={{
py: 1.8, py: 1.8,
borderRadius: '14px', borderRadius: '14px',
@ -264,7 +515,7 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
'&:hover': { bgcolor: '#38006b' }, '&:hover': { bgcolor: '#38006b' },
}} }}
> >
Pay {totalBill.toFixed(0)} via UPI / GPay / PhonePe Pay {billTotal.toFixed(0)} via UPI / GPay / PhonePe
</Button> </Button>
<Grid container spacing={1.5}> <Grid container spacing={1.5}>
@ -273,9 +524,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
fullWidth fullWidth
variant="outlined" variant="outlined"
size="large" size="large"
onClick={() => handlePay('card')} disabled={paying || !billId}
onClick={() => handleGatewayPay('card')}
startIcon={<CreditCardIcon />} 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 Debit / Credit Card
</Button> </Button>
@ -285,9 +543,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
fullWidth fullWidth
variant="outlined" variant="outlined"
size="large" size="large"
onClick={() => handlePay('cash')} disabled={paying}
onClick={handleCash}
startIcon={<CurrencyRupeeIcon />} 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 Pay by Cash
</Button> </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, Chip,
Card, Card,
CardMedia, CardMedia,
BottomNavigation,
BottomNavigationAction,
Badge,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AddIcon from '@mui/icons-material/Add'; 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 DeleteIcon from '@mui/icons-material/Delete';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; 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 { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData'; import type { MenuItem } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge'; import { StatusBadge } from '../common/StatusBadge';
interface CartReviewViewProps { interface CartReviewViewProps {
cart: { [itemId: string]: number }; cart: { [itemId: string]: number };
cartNotes?: { [itemId: string]: string };
menuItems?: MenuItem[];
onUpdateCart: (itemId: string, quantity: number) => void; onUpdateCart: (itemId: string, quantity: number) => void;
onPlaceOrder: (orderNotes: string) => void; onPlaceOrder: (orderNotes: string) => void;
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => 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> = ({ export const CartReviewView: React.FC<CartReviewViewProps> = ({
cart, cart,
cartNotes = {},
menuItems = MENU_ITEMS,
onUpdateCart, onUpdateCart,
onPlaceOrder, onPlaceOrder,
onNavigate, onNavigate,
}) => { }) => {
const [orderNotes, setOrderNotes] = useState<string>(''); const [orderNotes, setOrderNotes] = useState<string>('');
const [placing, setPlacing] = useState(false);
const cartEntries = Object.entries(cart) const cartEntries = Object.entries(cart)
.map(([id, qty]) => { .map(([id, qty]) => {
const item = MENU_ITEMS.find((m) => m.id === id); const item = menuItems.find((m) => String(m.id) === id);
return item ? { item, qty } : null; 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 subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
const cgst = subtotal * 0.025; // CGST 2.5% const cgst = subtotal * 0.025;
const sgst = subtotal * 0.025; // SGST 2.5% const sgst = subtotal * 0.025;
const serviceCharge = subtotal * 0.05; // 5% service charge const serviceCharge = subtotal * 0.05;
const grandTotal = subtotal + cgst + sgst + serviceCharge; 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 ( return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}> <Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: cartEntries.length ? 22 : 12 }}>
{/* Header */}
<Box <Box
sx={{ sx={{
position: 'sticky', position: 'sticky',
@ -68,22 +95,22 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
boxShadow: '0 2px 8px rgba(0,0,0,0.05)', boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<IconButton onClick={() => onNavigate('menu')} size="small"> <IconButton onClick={() => onNavigate('menu')} size="small" aria-label="Back to menu">
<ArrowBackIcon sx={{ color: '#ac2d00' }} /> <ArrowBackIcon sx={{ color: '#ac2d00' }} />
</IconButton> </IconButton>
<Box> <Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}> <Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.15 }}>
Your Cart Review order
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}> <Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'} Table {tableLabel()} · Check items before placing
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<Chip <Chip
label={`${cartEntries.length} item${cartEntries.length !== 1 ? 's' : ''}`} label={`${totalUnits} item${totalUnits === 1 ? '' : 's'}`}
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00' }} sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00', flexShrink: 0 }}
/> />
</Box> </Box>
@ -93,22 +120,29 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
elevation={0} elevation={0}
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }} 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 }}> <Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
Your cart is empty Your cart is empty
</Typography> </Typography>
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}> <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> </Typography>
<Button variant="contained" color="primary" onClick={() => onNavigate('menu')} <Button
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}> variant="contained"
Browse Menu color="primary"
onClick={() => onNavigate('menu')}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
>
Browse menu
</Button> </Button>
</Paper> </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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
{cartEntries.map(({ item, qty }) => ( {cartEntries.map(({ item, qty, notes }) => (
<Card <Card
key={item.id} key={item.id}
elevation={0} elevation={0}
@ -129,43 +163,84 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
alt={item.name} alt={item.name}
/> />
<Box sx={{ flex: 1, minWidth: 0 }}> <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 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
{item.name} {item.name}
</Typography> </Typography>
<StatusBadge dietary={item.dietary} /> <StatusBadge dietary={item.dietary} />
</Box> </Box>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', my: 0.5 }}> <Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{(item.price * qty).toFixed(0)} {item.price} each
</Typography> </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' }}> <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 }} /> <RemoveIcon sx={{ fontSize: 16 }} />
</IconButton> </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} {qty}
</Typography> </Typography>
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty + 1)}> <IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty + 1)}>
<AddIcon sx={{ fontSize: 16 }} /> <AddIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Box> </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 }} /> <DeleteIcon sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
</Box>
</Card> </Card>
))} ))}
</Box> </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' }}> <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 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
📝 Special Instructions Special instructions (optional)
</Typography> </Typography>
<TextField <TextField
placeholder="E.g. Extra spicy, no onion, serve starters first..." placeholder="E.g. Extra spicy, no onion, serve starters first"
multiline multiline
rows={2} rows={2}
fullWidth fullWidth
@ -176,60 +251,125 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
/> />
</Paper> </Paper>
{/* Bill Summary */} <Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} /> <ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}> <Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Bill Summary Bill summary
</Typography> </Typography>
</Box> </Box>
{[ {[
{ label: 'Items Total', value: `${subtotal.toFixed(0)}` }, { label: 'Items total', value: `${subtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` }, { label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.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) => ( ].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6 }}> <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={{ color: '#6b5c57' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography> {row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.value}
</Typography>
</Box> </Box>
))} ))}
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Grand Total</Typography> <Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>{grandTotal.toFixed(0)}</Typography> To pay
</Typography>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{grandTotal.toFixed(0)}
</Typography>
</Box> </Box>
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}> <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> </Typography>
</Paper> </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 <Button
fullWidth fullWidth
variant="contained" variant="contained"
size="large" size="large"
onClick={() => onPlaceOrder(orderNotes)} disabled={placing}
onClick={() => void handlePlace()}
endIcon={<SendIcon />} endIcon={<SendIcon />}
sx={{ sx={{
py: 2, py: 1.6,
borderRadius: '9999px', borderRadius: '9999px',
fontSize: '1.05rem', fontSize: '1rem',
fontWeight: 900, fontWeight: 900,
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)', background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)', boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' }, '&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
}} }}
> >
Place Order {grandTotal.toFixed(0)} {placing ? 'Placing order…' : `Place order · ₹${grandTotal.toFixed(0)}`}
</Button> </Button>
</>
)}
</Container> </Container>
</Box> </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 handleAdd = () => {
const formattedNotes = [ const formattedNotes = [
`Spice: ${spiceLevel}`, `Spice: ${spiceLevel}`,
extraCheese ? 'Extra Cheese (+ $2.50)' : '', extraCheese ? 'Extra Cheese (+₹40)' : '',
extraTruffle ? 'Extra Truffle Oil (+ $3.00)' : '', extraTruffle ? 'Extra Truffle Oil (+₹60)' : '',
specialNotes ? `Note: ${specialNotes}` : '', specialNotes ? `Note: ${specialNotes}` : '',
] ]
.filter(Boolean) .filter(Boolean)

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { import {
Box, Box,
Drawer, Drawer,
@ -10,6 +10,8 @@ import {
Button, Button,
Divider, Divider,
Fade, Fade,
CircularProgress,
TextField,
} from '@mui/material'; } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import MicIcon from '@mui/icons-material/Mic'; 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 SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart'; import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import SendIcon from '@mui/icons-material/Send';
import { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition'; import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
import type { VoiceState } from '../../hooks/useSpeechRecognition'; import type { VoiceState } from '../../hooks/useSpeechRecognition';
import { api } from '../../services/api';
import { debugLog, maskToken } from '../../utils/debugLog';
interface DetectedItem { export type AiCartLine = {
menuItem: MenuItem; menu_item_id: number;
qty: number; name: string;
} quantity: number;
unit_price: number | string;
notes: string[];
is_available: boolean;
};
interface VoiceAssistantModalProps { interface VoiceAssistantModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onAddToCart: (itemId: string, quantity: number) => void; onAddToCart: (itemId: string, quantity: number) => void;
} onSyncCart?: (cart: AiCartLine[]) => 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;
} }
const QUICK_COMMANDS = [ const QUICK_COMMANDS = [
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken and 2 garlic naan please' }, { 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 for table 12' }, { label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani please' },
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi" }, { 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: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' }, { label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
]; ];
@ -78,11 +54,118 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
open, open,
onClose, onClose,
onAddToCart, onAddToCart,
onSyncCart,
}) => { }) => {
const [internalVoiceState, setInternalVoiceState] = useState<VoiceState>('idle'); const [cartLines, setCartLines] = useState<AiCartLine[]>([]);
const [detectedItems, setDetectedItems] = useState<DetectedItem[]>([]); const [assistantText, setAssistantText] = useState('');
const [toolCalls, setToolCalls] = useState<string[]>([]);
const [confirmed, setConfirmed] = useState(false); 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 [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 { const {
voiceState: recognitionState, voiceState: recognitionState,
@ -93,101 +176,106 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
stopListening, stopListening,
resetTranscript, resetTranscript,
error, 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(() => { useEffect(() => {
if (transcript.trim().length > 0 && recognitionState === 'thinking') { if (!open) return;
const items = parseTranscriptToItems(transcript); debugLog.info('voice', 'modal opened', {
setDetectedItems(items); isSupported,
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.`,
selectedLang, selectedLang,
() => setInternalVoiceState('idle') hasSession: Boolean(sessionStorage.getItem('customer_session_token')),
); tableId: sessionStorage.getItem('customer_table_id'),
}, 500); tableNumber: sessionStorage.getItem('customer_table_number'),
} else { debug: debugLog.enabled(),
setTimeout(() => { });
speak( busyRef.current = false;
"Sorry, I couldn't identify any items from your order. Please try again or use the quick commands below.", setBusy(false);
selectedLang, setApiError(null);
() => setInternalVoiceState('idle') setAssistantText('');
); setToolCalls([]);
setInternalVoiceState('idle');
}, 400);
}
}
}, [transcript, recognitionState]);
const handleClose = useCallback(() => {
cancel();
resetTranscript();
setDetectedItems([]);
setConfirmed(false); 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(); onClose();
}, [cancel, resetTranscript, onClose]); };
const handleMicToggle = () => { 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(); stopListening();
} else { return;
setDetectedItems([]); }
setConfirmed(false); debugLog.info('stt', 'mic start', { lang: selectedLang });
setApiError(null);
resetTranscript(); resetTranscript();
startListening(selectedLang); startListening(selectedLang);
}
}; };
const handleQuickCommand = (text: string) => { const handleQuickCommand = (text: string) => {
const items = parseTranscriptToItems(text); debugLog.info('voice', 'quick command', { text: text.slice(0, 120) });
setDetectedItems(items); void runAiTurn(text, false);
setInternalVoiceState('thinking'); };
if (items.length > 0) {
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', '); const handleTypedSubmit = () => {
speak( debugLog.info('voice', 'typed submit', { text: typedOrder.slice(0, 120) });
`Great choice! Found ${itemList}. Tap confirm to add to your cart.`, void runAiTurn(typedOrder, false);
selectedLang,
() => setInternalVoiceState('idle')
);
}
}; };
const handleConfirm = () => { const handleConfirm = () => {
detectedItems.forEach((d) => onAddToCart(d.menuItem.id, d.qty)); const text =
setConfirmed(true); liveTranscript.trim() ||
speak('Items added to your cart! Enjoy your meal.', selectedLang); 'Please place my current cart as an order. I confirm submit.';
setTimeout(() => { void runAiTurn(text, true);
handleClose();
}, 2000);
}; };
const orbColor = { const orbColor = {
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' }, 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)' }, 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)' }, error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
}; };
const orbLabel = { const orbLabel = {
idle: 'Tap to Speak', idle: isSupported ? 'Tap to Speak' : 'Type or use quick commands',
listening: '🎙️ Listening...', listening: 'Listening… tap again to stop',
thinking: '🤔 Processing...', thinking: 'AI thinking…',
speaking: '🔊 AI Speaking...', speaking: 'Done',
error: '⚠️ Try Again', error: 'Try Again',
}; };
const shownTranscript = liveTranscript || transcript || interimTranscript;
return ( return (
<Drawer <Drawer
anchor="bottom" anchor="bottom"
@ -205,12 +293,10 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}} }}
> >
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}> <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={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} /> <Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
</Box> </Box>
{/* Header */}
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} /> <SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
@ -219,7 +305,7 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
AI Voice Assistant AI Voice Assistant
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}> <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> </Typography>
</Box> </Box>
</Box> </Box>
@ -228,51 +314,52 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</IconButton> </IconButton>
</Box> </Box>
{/* Scrollable Body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
{!isSupported && ( {!isSupported && (
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}> <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> </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 }}> <Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
{error} {apiError || error}
</Alert> </Alert>
)} )}
{/* Voice Orb */} <Box sx={{ textAlign: 'center', py: 2 }}>
<Box sx={{ textAlign: 'center', py: 3 }}>
<Box <Box
onClick={handleMicToggle} onClick={handleMicToggle}
sx={{ sx={{
width: 120, width: 112,
height: 120, height: 112,
borderRadius: '50%', borderRadius: '50%',
background: orbColor[voiceState].bg, background: orbColor[voiceState].bg,
boxShadow: orbColor[voiceState].shadow, boxShadow: orbColor[voiceState].shadow,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: busy ? 'wait' : 'pointer',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)', mb: 1.5,
transform: voiceState === 'listening' || voiceState === 'speaking' ? 'scale(1.12)' : 'scale(1)', pointerEvents: busy ? 'none' : 'auto',
mb: 2,
}} }}
> >
{voiceState === 'listening' ? ( {busy ? (
<GraphicEqIcon sx={{ color: '#fff', fontSize: 52 }} /> <CircularProgress size={40} sx={{ color: '#fff' }} />
) : voiceState === 'speaking' ? ( ) : voiceState === 'listening' ? (
<VolumeUpIcon sx={{ color: '#fff', fontSize: 52 }} /> <GraphicEqIcon sx={{ color: '#fff', fontSize: 48 }} />
) : voiceState === 'thinking' ? (
<SmartToyIcon sx={{ color: '#fff', fontSize: 52 }} />
) : ( ) : (
<MicIcon sx={{ color: '#fff', fontSize: 52 }} /> <MicIcon sx={{ color: '#fff', fontSize: 48 }} />
)} )}
</Box> </Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}> <Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
{orbLabel[voiceState]} {orbLabel[voiceState]}
</Typography> </Typography>
@ -281,22 +368,21 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<StopIcon />} startIcon={<StopIcon />}
onClick={stopListening} onClick={handleMicToggle}
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00' }} sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00', mb: 1 }}
> >
Stop & Process Stop & Process
</Button> </Button>
)} )}
{/* Language Toggle */} <Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1.5 }}> {(['en-IN', 'hi-IN'] as const).map((lang) => (
{['en-IN', 'hi-IN'].map((lang) => (
<Chip <Chip
key={lang} key={lang}
label={lang === 'en-IN' ? '🇮🇳 English' : '🇮🇳 हिन्दी'} label={lang === 'en-IN' ? 'English' : 'हिन्दी'}
size="small" size="small"
clickable clickable
onClick={() => setSelectedLang(lang as 'en-IN' | 'hi-IN')} onClick={() => setSelectedLang(lang)}
color={selectedLang === lang ? 'primary' : 'default'} color={selectedLang === lang ? 'primary' : 'default'}
variant={selectedLang === lang ? 'filled' : 'outlined'} variant={selectedLang === lang ? 'filled' : 'outlined'}
sx={{ fontWeight: 700 }} sx={{ fontWeight: 700 }}
@ -305,8 +391,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Box> </Box>
</Box> </Box>
{/* Live Transcript */} <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{(transcript || interimTranscript) && ( <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 <Paper
elevation={0} elevation={0}
sx={{ sx={{
@ -318,18 +425,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}} }}
> >
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}> <Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
WHAT I HEARD: ORDER TEXT:
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}> <Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
"{transcript}{interimTranscript && <span style={{ color: '#9e9e9e' }}>{interimTranscript}</span>}" "{shownTranscript}"
</Typography> </Typography>
</Paper> </Paper>
)} )}
{/* Detected Items */} {assistantText && (
<Fade in={detectedItems.length > 0}> <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> <Box>
{detectedItems.length > 0 && ( {cartLines.length > 0 && (
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
@ -341,11 +459,11 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
}} }}
> >
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
Items Detected: Cart from AI:
</Typography> </Typography>
{detectedItems.map((d) => ( {cartLines.map((line) => (
<Box <Box
key={d.menuItem.id} key={`${line.menu_item_id}-${line.quantity}-${line.notes.join(',')}`}
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -356,15 +474,17 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
> >
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}> <Typography variant="body2" sx={{ fontWeight: 800 }}>
{d.qty}× {d.menuItem.name} {line.quantity}× {line.name}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#546067' }}> <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> </Typography>
</Box> </Box>
<Chip <Chip
label={d.menuItem.dietary === 'non-veg' ? '🔴 Non-Veg' : '🟢 Veg'} label={line.is_available ? 'Available' : "86'd"}
size="small" size="small"
color={line.is_available ? 'success' : 'warning'}
sx={{ fontWeight: 700 }} sx={{ fontWeight: 700 }}
/> />
</Box> </Box>
@ -375,24 +495,27 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
fullWidth fullWidth
variant="outlined" variant="outlined"
size="small" size="small"
onClick={() => { setDetectedItems([]); resetTranscript(); }} onClick={() => {
setCartLines([]);
setAssistantText('');
setLiveTranscript('');
setToolCalls([]);
onSyncCart?.([]);
}}
sx={{ fontWeight: 700 }} sx={{ fontWeight: 700 }}
> >
Retry Clear
</Button> </Button>
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
size="large" size="large"
startIcon={confirmed ? <CheckCircleIcon /> : <AddShoppingCartIcon />} startIcon={confirmed ? <CheckCircleIcon /> : <SendIcon />}
onClick={handleConfirm} onClick={handleConfirm}
disabled={confirmed} disabled={confirmed || busy}
sx={{ sx={{ fontWeight: 800, bgcolor: confirmed ? '#006a2e' : '#ac2d00' }}
fontWeight: 800,
bgcolor: confirmed ? '#006a2e' : '#ac2d00',
}}
> >
{confirmed ? 'Added!' : 'Add to Cart'} {confirmed ? 'Ordered!' : 'Confirm & Place Order'}
</Button> </Button>
</Box> </Box>
</Paper> </Paper>
@ -400,7 +523,6 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Box> </Box>
</Fade> </Fade>
{/* Quick Commands */}
<Divider sx={{ my: 2 }}> <Divider sx={{ my: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}> <Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
QUICK COMMANDS QUICK COMMANDS
@ -408,9 +530,9 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
</Divider> </Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{QUICK_COMMANDS.map((cmd, idx) => ( {QUICK_COMMANDS.map((cmd) => (
<Paper <Paper
key={idx} key={cmd.label}
elevation={0} elevation={0}
onClick={() => handleQuickCommand(cmd.text)} onClick={() => handleQuickCommand(cmd.text)}
sx={{ sx={{
@ -418,18 +540,20 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
py: 1.5, py: 1.5,
borderRadius: '12px', borderRadius: '12px',
border: '1px solid #e4beb4', border: '1px solid #e4beb4',
cursor: 'pointer', cursor: busy ? 'wait' : 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 1.5, gap: 1.5,
opacity: busy ? 0.6 : 1,
pointerEvents: busy ? 'none' : 'auto',
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' }, '&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
transition: 'all 0.15s ease',
}} }}
> >
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} /> <SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
"{cmd.label}" "{cmd.label}"
</Typography> </Typography>
<AddShoppingCartIcon sx={{ ml: 'auto', color: '#ac2d00', fontSize: 16 }} />
</Paper> </Paper>
))} ))}
</Box> </Box>

View File

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

View File

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

View File

@ -1,210 +1,359 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Box, Box,
Typography, Typography,
Button, Button,
Chip, Chip,
Grid, Grid,
Paper,
Divider,
TextField,
Alert,
CircularProgress,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
Paper,
Divider,
} from '@mui/material'; } from '@mui/material';
import MicIcon from '@mui/icons-material/Mic'; import MicIcon from '@mui/icons-material/Mic';
import StopIcon from '@mui/icons-material/Stop';
import GraphicEqIcon from '@mui/icons-material/GraphicEq'; import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import SmartToyIcon from '@mui/icons-material/SmartToy'; import SmartToyIcon from '@mui/icons-material/SmartToy';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart'; import InventoryIcon from '@mui/icons-material/Inventory';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import type { VoiceCommandSuggestion } from '../../types'; import SendIcon from '@mui/icons-material/Send';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { api } from '../../services/api';
type LowStockRow = {
id: number;
name: string;
unit: string;
current_stock: number;
reorder_threshold: number;
is_low: boolean;
};
interface VoiceOrderingViewProps { interface VoiceOrderingViewProps {
onAddOrderFromVoice: (transcript: string) => void; onAddOrderFromVoice?: (transcript: string) => void;
} }
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = ({ onAddOrderFromVoice }) => { const QUICK_PROMPTS = [
const [isListening, setIsListening] = useState(false); { label: 'What is low stock?', text: 'What ingredients are low on stock right now?' },
const [transcript, setTranscript] = useState( { label: 'Add 2kg tomato', text: 'please add 2 kgs tomato in inventory' },
'Do Butter Chicken, teen Garlic Naan aur ek Mango Lassi Table 12 ke liye' { 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,
})),
); );
const [detectedItems] = useState([ })
{ name: 'Butter Chicken', qty: 2, price: 360, dietary: 'non-veg' as const }, .catch(() => {
{ name: 'Garlic Naan', qty: 3, price: 80, dietary: 'veg' as const }, /* ignore prefetch errors */
{ name: 'Mango Lassi', qty: 1, price: 120, dietary: 'veg' as const }, });
]); }, []);
const [submitted, setSubmitted] = useState(false);
const samplePrompts: VoiceCommandSuggestion[] = [ const toggleMic = () => {
{ if (voiceState === 'listening') {
id: '1', stopListening();
label: '2 Butter Chicken & Garlic Naan Table 12', const spoken = (transcript || interimTranscript || '').trim();
prompt: 'Do Butter Chicken aur do Garlic Naan Table 12 ke liye', if (spoken) void runStaffAi(spoken);
category: 'order', return;
}, }
{ resetTranscript();
id: '2', setLiveTranscript('');
label: 'Hyderabadi Biryani + Raita Table 8', startListening(selectedLang);
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);
}; };
const totalValue = detectedItems.reduce((sum, i) => sum + i.price * i.qty, 0); const shownTranscript = liveTranscript || transcript || interimTranscript;
const listening = voiceState === 'listening';
return ( return (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}> <Box sx={{ p: { xs: 2, md: 3 }, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ textAlign: 'center', mb: 4 }}> <Box sx={{ textAlign: 'center', mb: 3 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}> <Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
Staff Voice Assistant Terminal Staff AI Assistant
</Typography> </Typography>
<Typography variant="body1" sx={{ color: '#5b4139' }}> <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> </Typography>
</Box> </Box>
{/* Voice Orb */} <Grid container spacing={2.5}>
<Grid size={{ xs: 12, md: 5 }}>
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
p: 4, p: 3,
mb: 4, borderRadius: '20px',
textAlign: 'center',
borderRadius: '24px',
background: 'linear-gradient(180deg, #ffffff 0%, #ffdbd1 100%)',
border: '1px solid #e4beb4', border: '1px solid #e4beb4',
bgcolor: '#fff9f7',
textAlign: 'center',
minHeight: 360,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}} }}
> >
<Box <Box
onClick={() => setIsListening(!isListening)} onClick={toggleMic}
sx={{ sx={{
width: 130, width: 110,
height: 130, height: 110,
borderRadius: '50%', borderRadius: '50%',
mx: 'auto', display: 'grid',
mb: 3, placeItems: 'center',
cursor: 'pointer', cursor: 'pointer',
background: isListening color: '#fff',
? 'radial-gradient(circle, #d53e0b 0%, #ac2d00 70%)' background: listening
: 'radial-gradient(circle, #ac2d00 0%, #872100 100%)', ? 'linear-gradient(135deg, #d53e0b, #e75a2b)'
boxShadow: isListening : busy
? '0 0 0 18px rgba(213, 62, 11, 0.2), 0 0 0 36px rgba(172, 45, 0, 0.1)' ? 'linear-gradient(135deg, #845000, #b07000)'
: '0 4px 20px rgba(172, 45, 0, 0.3)', : 'linear-gradient(135deg, #ac2d00, #d53e0b)',
display: 'flex', boxShadow: listening
alignItems: 'center', ? '0 0 0 14px rgba(213,62,11,0.18)'
justifyContent: 'center', : '0 8px 24px rgba(172,45,0,0.35)',
color: '#ffffff',
transition: 'all 0.3s ease-in-out',
transform: isListening ? 'scale(1.08)' : 'scale(1)',
}} }}
> >
{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> </Box>
<Typography sx={{ fontWeight: 800, color: '#ac2d00' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}> {busy ? 'AI checking inventory…' : listening ? 'Listening… tap to stop' : isSupported ? 'Tap to speak' : 'Type a question below'}
{isListening ? '🎙️ Listening & Parsing Voice Input...' : 'Tap Orb or Speak to Begin Order'}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600, display: 'block', mb: 3 }}> <Box sx={{ display: 'flex', gap: 1 }}>
Multilingual: English · Hindi · Hinglish {(['en-IN', 'hi-IN'] as const).map((lang) => (
</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}>
<Chip <Chip
icon={<SmartToyIcon fontSize="small" />} key={lang}
label={s.label} label={lang === 'en-IN' ? 'English' : 'Hindi'}
onClick={() => { setTranscript(s.prompt); setIsListening(true); setTimeout(() => setIsListening(false), 1500); }} size="small"
clickable onClick={() => setSelectedLang(lang)}
sx={{ width: '100%', justifyContent: 'flex-start', py: 2.5, px: 1, fontWeight: 700, bgcolor: '#ffffff', border: '1px solid #e4beb4', '&:hover': { bgcolor: '#ffdbd1' } }} color={selectedLang === lang ? 'primary' : 'default'}
sx={{ fontWeight: 700 }}
/> />
</Grid>
))} ))}
</Grid>
</Box> </Box>
{(error || sttError) && (
{/* Detected Items */} <Alert severity="error" sx={{ width: '100%', textAlign: 'left', fontWeight: 600 }}>
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}> {error || sttError}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> </Alert>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>AI Extracted Items</Typography> )}
<Chip label="Table 12" color="primary" sx={{ fontWeight: 800 }} /> {shownTranscript && (
</Box> <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' }}>
<List disablePadding> You said
{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}×
</Typography> </Typography>
<ListItemText <Typography variant="body2" sx={{ fontWeight: 700 }}>{shownTranscript}</Typography>
primary={<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>} </Paper>
secondary={`Subtotal: ₹${(item.qty * item.price)}`} )}
</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> </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> </ListItem>
))} ))}
</List> </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> </Paper>
</Grid>
</Grid>
</Box> </Box>
); );
}; };

View File

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

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { debugLog } from '../utils/debugLog';
interface SpeechRecognitionEvent extends Event { interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList; results: SpeechRecognitionResultList;
@ -43,12 +44,28 @@ interface UseSpeechRecognitionReturn {
error: string | null; 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 [voiceState, setVoiceState] = useState<VoiceState>('idle');
const [transcript, setTranscript] = useState<string>(''); const [transcript, setTranscript] = useState('');
const [interimTranscript, setInterimTranscript] = useState<string>(''); const [interimTranscript, setInterimTranscript] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<ISpeechRecognition | 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 = const isSupported =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@ -56,84 +73,160 @@ export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
useEffect(() => { useEffect(() => {
return () => { return () => {
try {
recognitionRef.current?.abort(); 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) { 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'); setVoiceState('error');
return; 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 SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognitionAPI(); const recognition = new SpeechRecognitionAPI();
recognitionRef.current = recognition; recognitionRef.current = recognition;
recognition.lang = lang; // continuous=true: user taps stop; Chrome is more reliable this way than auto-end.
recognition.continuous = false; recognition.lang = lang === 'hi-IN' ? 'hi-IN' : 'en-IN';
recognition.continuous = true;
recognition.interimResults = true; recognition.interimResults = true;
recognition.maxAlternatives = 1; recognition.maxAlternatives = 1;
recognition.onstart = () => { recognition.onstart = () => {
debugLog.info('stt', 'recognition onstart', { lang: recognition.lang });
setVoiceState('listening'); setVoiceState('listening');
setError(null); setError(null);
setInterimTranscript('');
}; };
recognition.onresult = (event: SpeechRecognitionEvent) => { recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = ''; let interim = '';
let final = ''; let finals = finalBufferRef.current;
for (let i = event.resultIndex; i < event.results.length; i++) { for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]; const result = event.results[i];
const piece = result[0]?.transcript || '';
if (result.isFinal) { if (result.isFinal) {
final += result[0].transcript; finals = `${finals} ${piece}`.trim();
} else { } else {
interim += result[0].transcript; interim += piece;
} }
} }
finalBufferRef.current = finals;
interimRef.current = interim;
setTranscript(finals);
setInterimTranscript(interim); setInterimTranscript(interim);
if (final) { debugLog.info('stt', 'recognition onresult', {
setTranscript((prev) => prev + ' ' + final.trim()); final: finals.slice(0, 120),
setVoiceState('thinking'); interim: interim.slice(0, 120),
} });
}; };
recognition.onerror = (event: SpeechRecognitionErrorEvent) => { 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> = { const errorMessages: Record<string, string> = {
'no-speech': 'No speech detected. Please try again.', 'audio-capture': 'Microphone not accessible. Check Chrome site permissions.',
'audio-capture': 'Microphone not accessible.', 'not-allowed': 'Microphone blocked. Allow mic for localhost in Chrome.',
'not-allowed': 'Microphone permission denied. Please allow access.', network:
'network': 'Network error. Check your internet connection.', '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}`); setError(errorMessages[event.error] || `Voice error: ${event.error}`);
setVoiceState('error'); setVoiceState('error');
}; };
recognition.onend = () => { recognition.onend = () => {
setInterimTranscript(''); debugLog.info('stt', 'recognition onend', {
if (voiceState === 'listening') { finalBuf: finalBufferRef.current.slice(0, 120),
setVoiceState('thinking'); interimBuf: interimRef.current.slice(0, 120),
} submitted: submittedRef.current,
});
setVoiceState('idle');
emitFinal();
}; };
try { try {
recognition.start(); recognition.start();
} catch (e) { debugLog.info('stt', 'recognition.start() called', { lang: recognition.lang, continuous: true });
setError('Could not start voice recognition.'); } 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'); setVoiceState('error');
} }
}, [isSupported, voiceState]); },
[emitFinal, isSupported]
);
const stopListening = useCallback(() => { const stopListening = useCallback(() => {
recognitionRef.current?.stop(); const recognition = recognitionRef.current;
setVoiceState('thinking'); 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(() => { const resetTranscript = useCallback(() => {
finalBufferRef.current = '';
interimRef.current = '';
submittedRef.current = false;
setTranscript(''); setTranscript('');
setInterimTranscript(''); setInterimTranscript('');
setVoiceState('idle'); setVoiceState('idle');

View File

@ -15,7 +15,10 @@ export const useSpeechSynthesis = (
const speak = useCallback( const speak = useCallback(
(text: string, lang: string = 'en-IN', onEnd?: () => void) => { (text: string, lang: string = 'en-IN', onEnd?: () => void) => {
if (!isSupported) return; if (!isSupported) {
onEnd?.();
return;
}
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text); const utterance = new SpeechSynthesisUtterance(text);
@ -24,20 +27,27 @@ export const useSpeechSynthesis = (
utterance.pitch = 1.05; utterance.pitch = 1.05;
utterance.volume = 1; utterance.volume = 1;
utterance.onstart = () => { let settled = false;
setVoiceState?.('speaking'); const finish = () => {
}; if (settled) return;
settled = true;
utterance.onend = () => {
setVoiceState?.('idle'); setVoiceState?.('idle');
onEnd?.(); onEnd?.();
}; };
utterance.onerror = () => { utterance.onstart = () => {
setVoiceState?.('idle'); setVoiceState?.('speaking');
}; };
utterance.onend = finish;
utterance.onerror = finish;
try {
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
// Firefox can leave speech pending without onend — unblock UI.
window.setTimeout(finish, 5000);
} catch {
finish();
}
}, },
[isSupported, setVoiceState] [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 { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import './index.css';
import App from './App.tsx' import { CustomerApp } from './apps/customer/App';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <CustomerApp />
</StrictMode>, </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 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 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 // Helpers to get/set auth tokens
const getAccessToken = () => localStorage.getItem('access_token'); const getAccessToken = () => localStorage.getItem('access_token');
export { getAccessToken };
export const getRefreshToken = () => localStorage.getItem('refresh_token'); export const getRefreshToken = () => localStorage.getItem('refresh_token');
const setTokens = (access: string, refresh: string) => { const setTokens = (access: string, refresh: string) => {
localStorage.setItem('access_token', access); localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh); localStorage.setItem('refresh_token', refresh);
}; };
const clearTokens = () => { export const clearTokens = () => {
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
}; };
const getCustomerToken = () => sessionStorage.getItem('customer_session_token'); const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id'); 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_token', token);
sessionStorage.setItem('customer_session_id', sessionId); sessionStorage.setItem('customer_session_id', sessionId);
sessionStorage.setItem('customer_table_id', tableId); sessionStorage.setItem('customer_table_id', tableId);
sessionStorage.setItem('customer_restaurant_id', restaurantId); sessionStorage.setItem('customer_restaurant_id', restaurantId);
if (tableNumber) {
sessionStorage.setItem('customer_table_number', tableNumber);
}
}; };
export const clearCustomerSession = () => { export const clearCustomerSession = () => {
sessionStorage.removeItem('customer_session_token'); sessionStorage.removeItem('customer_session_token');
sessionStorage.removeItem('customer_session_id'); sessionStorage.removeItem('customer_session_id');
sessionStorage.removeItem('customer_table_id'); sessionStorage.removeItem('customer_table_id');
sessionStorage.removeItem('customer_restaurant_id'); sessionStorage.removeItem('customer_restaurant_id');
sessionStorage.removeItem('customer_table_number');
}; };
// Map front-end item ID (e.g. 'm1') to back-end numeric ID /** @deprecated Prefer numeric catalog ids from GET /menu/public */
export const getBackendMenuId = (frontendId: string): number => { export const getBackendMenuId = (frontendId: string | number): number => {
return parseInt(frontendId.replace(/[^0-9]/g, '')) || 1; 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 = (
export const getFrontendMenuItem = (backendId: number | string): MenuItem | undefined => { backendId: number | string,
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId)) || 1; catalog: MenuItem[] = MENU_ITEMS,
return MENU_ITEMS.find(m => m.id === `m${numId}`) || MENU_ITEMS[0]; ): 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 // Connection State tracking
@ -107,6 +209,13 @@ const mockDb = {
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> { async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
const url = `${BASE_URL}${path}`; const url = `${BASE_URL}${path}`;
const headers = new Headers(options.headers || {}); 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 // Inject Staff Auth Token
const staffToken = getAccessToken(); 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)) { if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json'); 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 { try {
const res = await fetch(url, { ...options, headers }); const res = await fetch(url, { ...options, headers });
triggerDemoMode(false); triggerDemoMode(false);
if (res.status === 401 && staffToken) { if (res.status === 401 && staffToken && !customerToken) {
// Token expired, clear it // Only clear staff auth when this was a staff-only request.
clearTokens(); clearTokens();
window.location.reload(); window.location.reload();
throw new Error('Staff session expired. Please log in again.'); throw new Error('Staff session expired. Please log in again.');
@ -144,11 +267,49 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
} catch { } catch {
parsedErr = { error: errText }; 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) { } 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); console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
triggerDemoMode(true); triggerDemoMode(true);
return handleMockRequest(path, options); 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]; return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
} }
// 7. KDS Board // 7. KDS Board + staff test order
if (path.startsWith('/kds/')) { 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 // Patch KDS order item status
@ -555,11 +754,16 @@ const notifyWsListeners = (event: any) => {
}); });
}; };
export type WsConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
export class RestroWebSocket { export class RestroWebSocket {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private url: string; private url: string;
private reconnectTimer: any = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private isClosedIntentional = false; private isClosedIntentional = false;
private hadOpenConnection = false;
onStateChange: ((state: WsConnectionState) => void) | null = null;
onReconnected: (() => void) | null = null;
constructor(channel: 'kds' | 'orders', param: string) { constructor(channel: 'kds' | 'orders', param: string) {
if (channel === 'kds') { if (channel === 'kds') {
@ -571,15 +775,30 @@ export class RestroWebSocket {
} }
} }
private setState(state: WsConnectionState) {
this.onStateChange?.(state);
}
connect() { connect() {
if (isDemoMode) { if (isDemoMode) {
console.log('Skipping real WebSocket connection in Demo Mode'); console.log('Skipping real WebSocket connection in Demo Mode');
this.setState('open');
return; return;
} }
this.isClosedIntentional = false; this.isClosedIntentional = false;
this.setState(this.hadOpenConnection ? 'reconnecting' : 'connecting');
try { try {
this.ws = new WebSocket(this.url); 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) => { this.ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@ -592,7 +811,10 @@ export class RestroWebSocket {
this.ws.onclose = () => { this.ws.onclose = () => {
if (!this.isClosedIntentional) { if (!this.isClosedIntentional) {
console.log('WebSocket closed. Attempting reconnect in 5s...'); console.log('WebSocket closed. Attempting reconnect in 5s...');
this.setState('reconnecting');
this.reconnectTimer = setTimeout(() => this.connect(), 5000); this.reconnectTimer = setTimeout(() => this.connect(), 5000);
} else {
this.setState('closed');
} }
}; };
@ -602,6 +824,8 @@ export class RestroWebSocket {
}; };
} catch (e) { } catch (e) {
console.error('WebSocket connection setup failed', 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.close();
this.ws = null; this.ws = null;
} }
this.setState('closed');
} }
} }
@ -646,19 +871,143 @@ export const api = {
return !!getAccessToken(); 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 // Tables & Session
startSession: async (tableId: number, language = 'en') => { startSession: async (tableId: number, language = 'en', tableNumber?: string) => {
const res = await apiRequest(`/tables/${tableId}/start-session`, { const res = await apiRequest(`/tables/${tableId}/start-session`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ language }) body: JSON.stringify({ language })
}); });
if (res.session_token) { 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; 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) => { closeSession: async (tableId: number) => {
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' }); 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) => { 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) => { getStaffOrderForSession: async (sessionId: string) => {
@ -718,10 +1076,39 @@ export const api = {
}, },
// Billing // Billing
generateBill: async (orderId: string) => { generateBill: async (orderId: string | number) => {
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' }); 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) => { recordPayment: async (billId: string, method: string, amount: number) => {
return apiRequest(`/bills/${billId}/record-payment`, { return apiRequest(`/bills/${billId}/record-payment`, {
method: 'POST', method: 'POST',
@ -766,6 +1153,10 @@ export const api = {
return apiRequest('/inventory/low-stock'); return apiRequest('/inventory/low-stock');
}, },
notifyLowStock: async () => {
return apiRequest('/inventory/notify-low-stock', { method: 'POST' });
},
// Recipes // Recipes
getRecipe: async (menuItemId: number) => { getRecipe: async (menuItemId: number) => {
return apiRequest(`/menu-items/${menuItemId}/recipe`); 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 // Suppliers CRUD
getSuppliers: async () => { getSuppliers: async () => {
return apiRequest('/suppliers'); return apiRequest('/suppliers');

View File

@ -1,145 +1,2 @@
import { createTheme } from '@mui/material/styles'; /** Re-export shared theme from @restroai/ui. */
export { theme } from '@restroai/ui';
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',
},
},
},
},
});

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

View File

@ -1,7 +1,86 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'node:path';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ 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,
},
});