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:
parent
a03a620acc
commit
fc4c42e20a
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
graphify-out
|
||||
.env
|
||||
10
.env.example
10
.env.example
|
|
@ -1,7 +1,9 @@
|
|||
# RestroAI Frontend Environment Variables
|
||||
|
||||
# The HTTP URL of the remote backend server
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
# Local full stack (docker compose up from retro_backend repo)
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_WS_URL=ws://localhost:8000
|
||||
|
||||
# The WebSocket URL of the remote backend server
|
||||
VITE_WS_URL=wss://api.yourdomain.com
|
||||
# Production
|
||||
# VITE_API_URL=https://restro-backend.navigolabs.com
|
||||
# VITE_WS_URL=wss://restro-backend.navigolabs.com
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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} didn’t 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');
|
||||
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
12
index.html
12
index.html
|
|
@ -2,16 +2,18 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RestroAI Operations Platform - Kitchen & Analytics Suite</title>
|
||||
<!-- Google Fonts: Inter, Plus Jakarta Sans & JetBrains Mono -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#ac2d00" />
|
||||
<meta name="description" content="RestroAI guest ordering — scan, order, pay at your table" />
|
||||
<link rel="apple-touch-icon" href="/icons.svg" />
|
||||
<title>RestroAI — Table Ordering</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/apps/customer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,6 +7,7 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:customer": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
|
@ -16,7 +17,10 @@
|
|||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { theme } from './theme';
|
||||
export { StatusBadge, DietaryBadge } from './StatusBadge';
|
||||
export type { DietaryType, OrderStatusLabel, OrderTypeLabel } from './StatusBadge';
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
335
src/App.tsx
335
src/App.tsx
|
|
@ -1,333 +1,2 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
|
||||
import { theme } from './theme/theme';
|
||||
import { TableLandingView } from './components/customer/TableLandingView';
|
||||
import { MenuBrowseView } from './components/customer/MenuBrowseView';
|
||||
import { CustomizationModal } from './components/customer/CustomizationModal';
|
||||
import { CartReviewView } from './components/customer/CartReviewView';
|
||||
import { OrderStatusView } from './components/customer/OrderStatusView';
|
||||
import { BillPaymentView } from './components/customer/BillPaymentView';
|
||||
import { VoiceAssistantModal } from './components/customer/VoiceAssistantModal';
|
||||
import { StaffLoginModal } from './components/admin/StaffLoginModal';
|
||||
import { AdminDesktopShell } from './components/admin/AdminDesktopShell';
|
||||
import type { MenuItem } from './data/menuData';
|
||||
import type { KDSOrder, OrderStatus } from './types';
|
||||
import { api, getBackendMenuId, getFrontendMenuItem, RestroWebSocket, subscribeToWsEvents, isDemoMode, setDemoModeChangeCallback } from './services/api';
|
||||
|
||||
type CustomerView = 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice';
|
||||
|
||||
const getTableNumFromUrl = (): number => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tableParam = params.get('table');
|
||||
if (tableParam) {
|
||||
const parsed = parseInt(tableParam);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
}
|
||||
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/scan\/(\d+)/);
|
||||
if (match) {
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
|
||||
return 12;
|
||||
};
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [appMode, setAppMode] = useState<'customer' | 'staff'>('customer');
|
||||
const [customerView, setCustomerView] = useState<CustomerView>('landing');
|
||||
const [staffRole, setStaffRole] = useState<'kitchen' | 'manager'>('kitchen');
|
||||
const [staffLoginOpen, setStaffLoginOpen] = useState<boolean>(false);
|
||||
const [voiceModalOpen, setVoiceModalOpen] = useState<boolean>(false);
|
||||
const [demoActive, setDemoActive] = useState<boolean>(isDemoMode);
|
||||
|
||||
const [cart, setCart] = useState<{ [itemId: string]: number }>({});
|
||||
const [customizingItem, setCustomizingItem] = useState<MenuItem | null>(null);
|
||||
const [orders, setOrders] = useState<KDSOrder[]>([]);
|
||||
|
||||
// Monitor demo mode status
|
||||
useEffect(() => {
|
||||
setDemoActive(isDemoMode);
|
||||
setDemoModeChangeCallback((demo) => {
|
||||
setDemoActive(demo);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch KDS board when staff logged in
|
||||
const loadKDS = async () => {
|
||||
try {
|
||||
// Backend returns KdsBoardResponse: { restaurant_id, orders: KdsBoardOrder[] }
|
||||
const data = await api.getKDSBoard(1); // 1 = restaurant ID
|
||||
const ordersArray = Array.isArray(data) ? data : (data?.orders || []);
|
||||
|
||||
// Map API Order model to KDSOrder type if format differs
|
||||
const mappedOrders: KDSOrder[] = ordersArray.map((ord: any) => {
|
||||
// Map KDS status to Order status
|
||||
let overallStatus: OrderStatus = 'pending';
|
||||
if (ord.status === 'served') overallStatus = 'served';
|
||||
else if (ord.status === 'ready') overallStatus = 'ready';
|
||||
else if (ord.status === 'in_preparation' || ord.status === 'preparing') overallStatus = 'preparing';
|
||||
|
||||
return {
|
||||
id: String(ord.id || ord.order_id),
|
||||
ticketNumber: ord.ticketNumber || String(ord.id || ord.order_id).substring(0, 4),
|
||||
tableNumber: ord.tableNumber || (ord.table_number ? String(ord.table_number) : 'Takeaway'),
|
||||
orderType: ord.orderType || (ord.channel === 'touch' ? 'Dine-In' : 'Dine-In'),
|
||||
status: overallStatus,
|
||||
createdAt: ord.createdAt || ord.placed_at || new Date().toISOString(),
|
||||
timeElapsedMinutes: ord.timeElapsedMinutes || 0,
|
||||
priority: ord.priority || 'normal',
|
||||
serverName: ord.serverName || 'System',
|
||||
totalAmount: ord.totalAmount || Number(ord.subtotal || 0),
|
||||
items: ord.items?.map((it: any) => {
|
||||
const original = getFrontendMenuItem(it.menu_item_id);
|
||||
return {
|
||||
id: String(it.id),
|
||||
name: it.name || original?.name || `Dish ${it.menu_item_id}`,
|
||||
quantity: it.quantity,
|
||||
price: it.price || (it.unit_price ? Number(it.unit_price) : (original?.price || 150)),
|
||||
completed: it.kds_status === 'ready' || it.kds_status === 'served',
|
||||
dietary: 'veg'
|
||||
};
|
||||
}) || []
|
||||
};
|
||||
});
|
||||
|
||||
setOrders(mappedOrders);
|
||||
} catch (err) {
|
||||
console.error('Failed to reload KDS board', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (appMode === 'staff') {
|
||||
loadKDS();
|
||||
|
||||
// Setup WebSocket connection
|
||||
const ws = new RestroWebSocket('kds', '1');
|
||||
ws.connect();
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubscribe = subscribeToWsEvents((event) => {
|
||||
if (event.type === 'kds.item_updated') {
|
||||
loadKDS();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [appMode]);
|
||||
|
||||
const handleUpdateCart = (itemId: string, quantity: number) => {
|
||||
setCart((prev) => {
|
||||
const copy = { ...prev };
|
||||
if (quantity <= 0) {
|
||||
delete copy[itemId];
|
||||
} else {
|
||||
copy[itemId] = quantity;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomizationConfirm = (item: MenuItem, quantity: number, _notes: string) => {
|
||||
handleUpdateCart(item.id, (cart[item.id] || 0) + quantity);
|
||||
};
|
||||
|
||||
const handleStartOrdering = async (language: string) => {
|
||||
try {
|
||||
const tableId = getTableNumFromUrl();
|
||||
await api.startSession(tableId, language === 'english' ? 'en' : 'hi');
|
||||
setCustomerView('menu');
|
||||
} catch (err) {
|
||||
console.warn('Could not initialize session, entering offline menu browse', err);
|
||||
setCustomerView('menu');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaceOrder = async (_orderNotes: string) => {
|
||||
try {
|
||||
const items = Object.entries(cart).map(([id, qty]) => ({
|
||||
menu_item_id: getBackendMenuId(id),
|
||||
quantity: qty,
|
||||
customization_notes: []
|
||||
}));
|
||||
|
||||
await api.placeOrder(items);
|
||||
setCart({});
|
||||
setCustomerView('status');
|
||||
} catch (err) {
|
||||
alert('Failed to place order: ' + err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
|
||||
try {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'queued',
|
||||
preparing: 'in_prep',
|
||||
ready: 'ready',
|
||||
served: 'served'
|
||||
};
|
||||
const kdsStatus = statusMap[newStatus];
|
||||
|
||||
const order = orders.find((o) => o.id === orderId);
|
||||
if (order) {
|
||||
for (const item of order.items) {
|
||||
await api.updateOrderItemStatus(item.id, kdsStatus);
|
||||
}
|
||||
}
|
||||
loadKDS();
|
||||
} catch (err) {
|
||||
console.error('Failed to change status', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleItem = async (orderId: string, itemId: string) => {
|
||||
try {
|
||||
const order = orders.find((o) => o.id === orderId);
|
||||
const item = order?.items.find((i) => i.id === itemId);
|
||||
if (item) {
|
||||
const nextStatus = item.completed ? 'in_prep' : 'ready';
|
||||
await api.updateOrderItemStatus(itemId, nextStatus);
|
||||
loadKDS();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSampleOrder = async () => {
|
||||
// Add a sample order via API or locally
|
||||
try {
|
||||
const items = [
|
||||
{ menu_item_id: 9, quantity: 2 }, // Butter chicken
|
||||
{ menu_item_id: 11, quantity: 4 } // Garlic naan
|
||||
];
|
||||
await api.placeOrder(items);
|
||||
loadKDS();
|
||||
} catch (e) {
|
||||
console.error('Failed to add sample order', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrderFromVoice = async (_transcript: string) => {
|
||||
try {
|
||||
const items = [
|
||||
{ menu_item_id: 9, quantity: 2, customization_notes: ['Via Voice assistant'] },
|
||||
{ menu_item_id: 11, quantity: 3 }
|
||||
];
|
||||
await api.placeOrder(items);
|
||||
loadKDS();
|
||||
} catch (e) {
|
||||
console.error('Failed to place voice order', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStaffLoginSuccess = (role: 'kitchen' | 'manager') => {
|
||||
setStaffRole(role);
|
||||
setAppMode('staff');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
||||
{demoActive && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="filled"
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
justifyContent: 'center',
|
||||
fontWeight: 800,
|
||||
py: 0.5,
|
||||
fontSize: '0.8rem',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2000,
|
||||
background: 'linear-gradient(90deg, #d32f2f 0%, #ef5350 100%)',
|
||||
}}
|
||||
>
|
||||
Running in Demo Mode (Mock Backend). Start restroai-backend locally to connect to live DB.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{appMode === 'staff' ? (
|
||||
<AdminDesktopShell
|
||||
role={staffRole}
|
||||
orders={orders}
|
||||
onStatusChange={handleStatusChange}
|
||||
onToggleItem={handleToggleItem}
|
||||
onAddSampleOrder={handleAddSampleOrder}
|
||||
onAddOrderFromVoice={handleAddOrderFromVoice}
|
||||
onLogout={() => setAppMode('customer')}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2' }}>
|
||||
{customerView === 'landing' && (
|
||||
<TableLandingView
|
||||
onStartOrdering={handleStartOrdering}
|
||||
onOpenStaffLogin={() => setStaffLoginOpen(true)}
|
||||
onOpenVoice={() => setVoiceModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customerView === 'menu' && (
|
||||
<MenuBrowseView
|
||||
cart={cart}
|
||||
onUpdateCart={handleUpdateCart}
|
||||
onOpenCustomization={(item) => setCustomizingItem(item)}
|
||||
onOpenVoice={() => setVoiceModalOpen(true)}
|
||||
onNavigate={(v) => setCustomerView(v as CustomerView)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customerView === 'cart' && (
|
||||
<CartReviewView
|
||||
cart={cart}
|
||||
onUpdateCart={handleUpdateCart}
|
||||
onPlaceOrder={handlePlaceOrder}
|
||||
onNavigate={(v) => setCustomerView(v as CustomerView)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customerView === 'status' && (
|
||||
<OrderStatusView onNavigate={(v) => setCustomerView(v as CustomerView)} />
|
||||
)}
|
||||
|
||||
{customerView === 'bill' && (
|
||||
<BillPaymentView onNavigate={(v) => setCustomerView(v as CustomerView)} />
|
||||
)}
|
||||
|
||||
{/* Item Customization Dialog */}
|
||||
<CustomizationModal
|
||||
open={!!customizingItem}
|
||||
item={customizingItem}
|
||||
onClose={() => setCustomizingItem(null)}
|
||||
onConfirm={handleCustomizationConfirm}
|
||||
/>
|
||||
|
||||
{/* AI Voice Assistant Modal (bottom sheet) */}
|
||||
<VoiceAssistantModal
|
||||
open={voiceModalOpen}
|
||||
onClose={() => setVoiceModalOpen(false)}
|
||||
onAddToCart={handleUpdateCart}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Staff Login Modal */}
|
||||
<StaffLoginModal
|
||||
open={staffLoginOpen}
|
||||
onClose={() => setStaffLoginOpen(false)}
|
||||
onLoginSuccess={handleStaffLoginSuccess}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
/** @deprecated Use CustomerApp from src/apps/customer — kept for tooling that imports ./App */
|
||||
export { CustomerApp as default, CustomerApp as App } from './apps/customer/App';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ import {
|
|||
Button,
|
||||
Avatar,
|
||||
Divider,
|
||||
Alert,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
|
|
@ -28,6 +33,9 @@ import InventoryIcon from '@mui/icons-material/Inventory';
|
|||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||
import BadgeIcon from '@mui/icons-material/Badge';
|
||||
import StarHalfIcon from '@mui/icons-material/StarHalf';
|
||||
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
|
||||
import WifiOffIcon from '@mui/icons-material/WifiOff';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
|
||||
import { KDSKanban } from '../kds/KDSKanban';
|
||||
import { AnalyticsDashboard } from '../analytics/AnalyticsDashboard';
|
||||
|
|
@ -39,21 +47,30 @@ import { InventoryView } from './InventoryView';
|
|||
import { SuppliersView } from './SuppliersView';
|
||||
import { StaffShiftsView } from './StaffShiftsView';
|
||||
import { FeedbackInboxView } from './FeedbackInboxView';
|
||||
import { MenuManagementView } from './MenuManagementView';
|
||||
import { LowStockNotifier } from './LowStockNotifier';
|
||||
|
||||
import type { KDSOrder, OrderStatus } from '../../types';
|
||||
import type { StaffRole } from './StaffLoginModal';
|
||||
|
||||
interface AdminDesktopShellProps {
|
||||
role: 'kitchen' | 'manager';
|
||||
role: StaffRole;
|
||||
orders: KDSOrder[];
|
||||
restaurantId: number;
|
||||
restaurants?: { id: number; name: string; is_home?: boolean; is_active?: boolean }[];
|
||||
onSwitchRestaurant?: (restaurantId: number) => void;
|
||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||
onToggleItem: (orderId: string, itemId: string) => void;
|
||||
onAddSampleOrder: () => void;
|
||||
onAddOrderFromVoice: (transcript: string) => void;
|
||||
onLogout: () => void;
|
||||
wsState?: 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||
busyOrderId?: string | null;
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| 'kds'
|
||||
| 'menu'
|
||||
| 'tables'
|
||||
| 'billing'
|
||||
| 'reservations'
|
||||
|
|
@ -64,56 +81,78 @@ type TabType =
|
|||
| 'analytics'
|
||||
| 'voice';
|
||||
|
||||
const defaultTabForRole = (role: StaffRole): TabType => {
|
||||
if (role === 'manager') return 'analytics';
|
||||
if (role === 'cashier') return 'billing';
|
||||
if (role === 'waiter') return 'tables';
|
||||
return 'kds';
|
||||
};
|
||||
|
||||
export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
||||
role,
|
||||
orders,
|
||||
restaurantId,
|
||||
restaurants = [],
|
||||
onSwitchRestaurant,
|
||||
onStatusChange,
|
||||
onToggleItem,
|
||||
onAddSampleOrder,
|
||||
onAddOrderFromVoice,
|
||||
onLogout,
|
||||
wsState = 'idle',
|
||||
busyOrderId = null,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>(
|
||||
role === 'manager' ? 'analytics' : 'kds'
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>(defaultTabForRole(role));
|
||||
|
||||
const pendingCount = orders.filter((o) => o.status === 'pending').length;
|
||||
const drawerWidth = 260;
|
||||
const drawerWidth = 232;
|
||||
|
||||
// Sidebar list configurations
|
||||
const menuItems = [
|
||||
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon />, roles: ['kitchen', 'manager'] },
|
||||
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon />, roles: ['manager'] },
|
||||
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon />, roles: ['manager'] },
|
||||
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon />, roles: ['manager'] },
|
||||
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon />, roles: ['kitchen', 'manager'] },
|
||||
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon />, roles: ['manager'] },
|
||||
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon />, roles: ['manager'] },
|
||||
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon />, roles: ['manager'] },
|
||||
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon />, roles: ['manager'] },
|
||||
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon />, roles: ['kitchen', 'manager'] },
|
||||
// Sidebar list configurations — matches roadmap §2.4
|
||||
const menuItems: { id: TabType; label: string; icon: React.ReactNode; roles: StaffRole[] }[] = [
|
||||
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
|
||||
{ id: 'menu', label: 'Menu Management', icon: <RestaurantMenuIcon fontSize="small" />, roles: ['manager'] },
|
||||
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon fontSize="small" />, roles: ['waiter', 'manager'] },
|
||||
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon fontSize="small" />, roles: ['cashier', 'waiter', 'manager'] },
|
||||
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon fontSize="small" />, roles: ['waiter', 'manager'] },
|
||||
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon fontSize="small" />, roles: ['chef', 'manager'] },
|
||||
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon fontSize="small" />, roles: ['manager'] },
|
||||
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon fontSize="small" />, roles: ['manager'] },
|
||||
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon fontSize="small" />, roles: ['manager'] },
|
||||
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon fontSize="small" />, roles: ['manager'] },
|
||||
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
|
||||
];
|
||||
|
||||
const filteredMenuItems = menuItems.filter((item) => item.roles.includes(role));
|
||||
|
||||
const getPageTitle = () => {
|
||||
switch (activeTab) {
|
||||
case 'kds': return 'Kitchen Display System (KDS) Live Kanban';
|
||||
case 'tables': return 'Table Floor & Session Planner';
|
||||
case 'billing': return 'Cashier Billing Desk & Payments';
|
||||
case 'reservations': return 'Diner Reservations Book';
|
||||
case 'inventory': return 'Kitchen Raw Stocks & Recipes Linking';
|
||||
case 'suppliers': return 'Merchant Suppliers & Purchase Orders';
|
||||
case 'staff': return 'Employee Attendance & Shift Roster';
|
||||
case 'feedback': return 'Customer Feedback Rating Reviews';
|
||||
case 'analytics': return 'Executive Business Analytics & Trends';
|
||||
case 'voice': return 'Staff AI Voice Assistant Terminal';
|
||||
default: return 'Management Portal';
|
||||
case 'kds': return 'KDS Live Kanban';
|
||||
case 'menu': return 'Menu Management';
|
||||
case 'tables': return 'Tables & Floor';
|
||||
case 'billing': return 'Billing Desk';
|
||||
case 'reservations': return 'Reservations';
|
||||
case 'inventory': return 'Kitchen Stock & Recipes';
|
||||
case 'suppliers': return 'Suppliers & POs';
|
||||
case 'staff': return 'Staff & Shifts';
|
||||
case 'feedback': return 'Customer Feedback';
|
||||
case 'analytics': return 'Business Intelligence';
|
||||
case 'voice': return 'AI Voice Terminal';
|
||||
default: return 'Staff Portal';
|
||||
}
|
||||
};
|
||||
|
||||
const roleLabel =
|
||||
role === 'manager'
|
||||
? 'Admin Manager'
|
||||
: role === 'cashier'
|
||||
? 'Cashier'
|
||||
: role === 'waiter'
|
||||
? 'Waiter'
|
||||
: 'Chef';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: '#f4f5f7' }}>
|
||||
<Box sx={{ display: 'flex', width: '100%', minHeight: '100vh', bgcolor: '#f4f5f7', textAlign: 'left' }}>
|
||||
<LowStockNotifier enabled={role === 'manager' || role === 'chef'} autoNotify={role === 'manager'} />
|
||||
{/* Desktop Sidebar Navigation */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
|
|
@ -126,84 +165,122 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
|||
bgcolor: '#1a1c1c',
|
||||
color: '#ffffff',
|
||||
borderRight: '1px solid #2f3131',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', gap: 1.25, flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LocalDiningIcon />
|
||||
<LocalDiningIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.15, fontSize: '1.05rem' }}>
|
||||
RestroAI
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.65rem', fontWeight: 700 }}>
|
||||
DESKTOP STAFF PORTAL
|
||||
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.04em' }}>
|
||||
STAFF PORTAL
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ px: 1.5, py: 1.5, flexShrink: 0 }}>
|
||||
<Chip
|
||||
avatar={<Avatar sx={{ bgcolor: '#ac2d00' }}>{role === 'manager' ? 'M' : 'K'}</Avatar>}
|
||||
label={role === 'manager' ? 'Role: Restaurant Manager' : 'Role: Kitchen Head Chef'}
|
||||
avatar={
|
||||
<Avatar sx={{ bgcolor: '#ac2d00', width: 24, height: 24, fontSize: '0.75rem' }}>
|
||||
{role === 'manager' ? 'M' : role === 'cashier' ? 'C' : role === 'waiter' ? 'W' : 'K'}
|
||||
</Avatar>
|
||||
}
|
||||
label={roleLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
mb: restaurants.length > 1 ? 1.25 : 0,
|
||||
}}
|
||||
/>
|
||||
{restaurants.length > 1 && onSwitchRestaurant && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="restaurant-picker-label" sx={{ color: '#c6c6cd' }}>
|
||||
Location
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="restaurant-picker-label"
|
||||
label="Location"
|
||||
value={restaurantId}
|
||||
onChange={(e) => onSwitchRestaurant(Number(e.target.value))}
|
||||
sx={{
|
||||
color: '#fff',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.2)' },
|
||||
'.MuiSvgIcon-root': { color: '#ffb5a0' },
|
||||
}}
|
||||
>
|
||||
{restaurants.map((r) => (
|
||||
<MenuItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
{r.is_home ? ' (home)' : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<List sx={{ px: 1, overflowY: 'auto', flexGrow: 1 }}>
|
||||
<List sx={{ px: 1, py: 0.5, overflowY: 'auto', flexGrow: 1, minHeight: 0 }}>
|
||||
{filteredMenuItems.map((item) => (
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }} key={item.id}>
|
||||
<ListItem disablePadding sx={{ mb: 0.25 }} key={item.id}>
|
||||
<ListItemButton
|
||||
selected={activeTab === item.id}
|
||||
onClick={() => setActiveTab(item.id as TabType)}
|
||||
dense
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
py: 0.75,
|
||||
'&.Mui-selected': { bgcolor: '#ac2d00', color: '#ffffff' },
|
||||
'&.Mui-selected:hover': { bgcolor: '#872100' },
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 38 }}>
|
||||
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 34 }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{ fontWeight: activeTab === item.id ? 800 : 500, fontSize: '0.9rem' }}>
|
||||
<Typography sx={{ fontWeight: activeTab === item.id ? 700 : 500, fontSize: '0.8125rem', lineHeight: 1.3 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
{item.id === 'kds' && pendingCount > 0 && (
|
||||
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20 }} />
|
||||
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20, ml: 0.5 }} />
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 'auto', p: 2 }}>
|
||||
<Box sx={{ p: 1.5, flexShrink: 0, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onLogout}
|
||||
startIcon={<LogoutIcon />}
|
||||
sx={{
|
||||
|
|
@ -217,46 +294,97 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
|||
},
|
||||
}}
|
||||
>
|
||||
Switch to Diner Mode
|
||||
Log out
|
||||
</Button>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Box component="main" sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2' }}>
|
||||
<Toolbar sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(wsState === 'reconnecting' || wsState === 'connecting') && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<WifiOffIcon />}
|
||||
sx={{ borderRadius: 0, fontWeight: 700, py: 0.5 }}
|
||||
>
|
||||
Reconnecting to kitchen… board will refresh when live.
|
||||
</Alert>
|
||||
)}
|
||||
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2', flexShrink: 0 }}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
minHeight: { xs: 56, sm: 60 },
|
||||
px: { xs: 2, md: 3 },
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: '#ac2d00',
|
||||
fontSize: { xs: '1rem', md: '1.15rem' },
|
||||
lineHeight: 1.25,
|
||||
minWidth: 0,
|
||||
flex: '1 1 auto',
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{getPageTitle()}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5 }, flexShrink: 0 }}>
|
||||
<Chip
|
||||
label="System Status: LIVE ONLINE"
|
||||
color="success"
|
||||
icon={wsState === 'open' ? <WifiIcon /> : <WifiOffIcon />}
|
||||
label={
|
||||
wsState === 'open'
|
||||
? 'Kitchen live'
|
||||
: wsState === 'reconnecting' || wsState === 'connecting'
|
||||
? 'Reconnecting…'
|
||||
: 'WS idle'
|
||||
}
|
||||
color={wsState === 'open' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 800 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AccountCircleIcon sx={{ color: '#ac2d00' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Logged in as {role === 'manager' ? 'Admin Manager' : 'Chef Rahul S.'}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<AccountCircleIcon sx={{ color: '#ac2d00', fontSize: 22 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, whiteSpace: 'nowrap', display: { xs: 'none', sm: 'block' } }}>
|
||||
{roleLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: 3, flexGrow: 1, overflowY: 'auto' }}>
|
||||
<Box sx={{ px: { xs: 1.5, md: 2.5 }, py: { xs: 1.5, md: 2 }, flexGrow: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{activeTab === 'kds' && (
|
||||
<KDSKanban
|
||||
orders={orders}
|
||||
busyOrderId={busyOrderId}
|
||||
onStatusChange={onStatusChange}
|
||||
onToggleItem={onToggleItem}
|
||||
onAddSampleOrder={onAddSampleOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'menu' && <MenuManagementView />}
|
||||
|
||||
{activeTab === 'tables' && <TableManagementView />}
|
||||
|
||||
{activeTab === 'billing' && <BillingManagerView />}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
MenuItem,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
|
@ -133,9 +134,30 @@ export const FeedbackInboxView: React.FC = () => {
|
|||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
|
||||
{fb.comment_text || '(No comment left by guest)'}
|
||||
</Typography>
|
||||
{(fb.sentiment_results || []).length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
|
||||
{fb.sentiment_results.map((row: any) => (
|
||||
<Chip
|
||||
key={`${fb.id}-${row.dimension}-${row.id || row.sentiment}`}
|
||||
size="small"
|
||||
label={`${row.dimension}: ${row.sentiment}`}
|
||||
color={
|
||||
row.sentiment === 'positive'
|
||||
? 'success'
|
||||
: row.sentiment === 'negative'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
sx={{ fontWeight: 700, textTransform: 'capitalize' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
|
|
|||
|
|
@ -36,10 +36,11 @@ import AddIcon from '@mui/icons-material/Add';
|
|||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { api } from '../../services/api';
|
||||
import { MENU_ITEMS } from '../../data/menuData';
|
||||
import type { StaffMenuItem } from '../../services/api';
|
||||
|
||||
export const InventoryView: React.FC = () => {
|
||||
const [ingredients, setIngredients] = useState<any[]>([]);
|
||||
const [menuDishes, setMenuDishes] = useState<StaffMenuItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -65,8 +66,16 @@ export const InventoryView: React.FC = () => {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getIngredients();
|
||||
const [data, dishes] = await Promise.all([
|
||||
api.getIngredients(),
|
||||
api.listMenuItems().catch(() => []),
|
||||
]);
|
||||
setIngredients(Array.isArray(data) ? data : []);
|
||||
const dishList = Array.isArray(dishes) ? dishes : [];
|
||||
setMenuDishes(dishList);
|
||||
if (dishList.length && !dishList.some((d) => d.id === selectedMenuId)) {
|
||||
setSelectedMenuId(dishList[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch ingredients');
|
||||
} finally {
|
||||
|
|
@ -271,9 +280,9 @@ export const InventoryView: React.FC = () => {
|
|||
label="Select Menu Dish"
|
||||
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem key={item.id} value={parseInt(item.id.replace('m', ''))}>
|
||||
{item.name} (₹{item.price})
|
||||
{menuDishes.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.name} (₹{Number(item.price)})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,15 +13,24 @@ import {
|
|||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import BadgeIcon from '@mui/icons-material/Badge';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
export type StaffRole = 'chef' | 'waiter' | 'cashier' | 'manager';
|
||||
|
||||
interface StaffLoginModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onLoginSuccess: (role: 'kitchen' | 'manager') => void;
|
||||
onLoginSuccess: (role: StaffRole) => void;
|
||||
}
|
||||
|
||||
const normalizeRole = (role: string): StaffRole => {
|
||||
const value = role.trim().toLowerCase();
|
||||
if (value === 'manager') return 'manager';
|
||||
if (value === 'waiter') return 'waiter';
|
||||
if (value === 'cashier') return 'cashier';
|
||||
return 'chef';
|
||||
};
|
||||
|
||||
export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -43,10 +52,8 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
|||
setError('');
|
||||
try {
|
||||
await api.login(username, password);
|
||||
// Determine role based on JWT claims or username
|
||||
const role = username.includes('manager') ? 'manager' : 'kitchen';
|
||||
onLoginSuccess(role);
|
||||
onClose();
|
||||
const me = await api.getMe();
|
||||
onLoginSuccess(normalizeRole(me.role));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed. Please check your credentials.');
|
||||
} finally {
|
||||
|
|
@ -81,7 +88,7 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
|||
Staff & Admin Portal Login
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#5b4139' }}>
|
||||
Authorized Restaurant Personnel Only
|
||||
Role comes from JWT via /auth/me
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
|
|
@ -97,70 +104,62 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
|||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
|
||||
QUICK AUTOFILL DEMO LOGIN:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
icon={<BadgeIcon fontSize="small" />}
|
||||
label="Manager Account"
|
||||
label="Manager"
|
||||
clickable
|
||||
onClick={() => fillCredentials('manager@test.com', 'managerpass')}
|
||||
sx={{ fontWeight: 700, flex: 1 }}
|
||||
onClick={() => fillCredentials('manager@demo.restro', 'Manager@12345')}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<BadgeIcon fontSize="small" />}
|
||||
label="Chef / Waiter Account"
|
||||
label="Chef"
|
||||
clickable
|
||||
onClick={() => fillCredentials('waiter@test.com', 'waiterpass')}
|
||||
sx={{ fontWeight: 700, flex: 1 }}
|
||||
onClick={() => fillCredentials('chef@demo.restro', 'Chef@12345')}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<Chip
|
||||
label="Waiter"
|
||||
clickable
|
||||
onClick={() => fillCredentials('waiter@demo.restro', 'Waiter@12345')}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<Chip
|
||||
label="Cashier"
|
||||
clickable
|
||||
onClick={() => fillCredentials('cashier@demo.restro', 'Cashier@12345')}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Email Address / Username"
|
||||
type="email"
|
||||
placeholder="manager@test.com"
|
||||
label="Email"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
sx={{ mb: 2.5 }}
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 800,
|
||||
bgcolor: '#ac2d00',
|
||||
'&:hover': { bgcolor: '#872100' },
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Access Portal'}
|
||||
<DialogActions sx={{ px: 0, pt: 2 }}>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2, justifyContent: 'center' }}>
|
||||
<Button size="small" onClick={onClose} color="inherit" disabled={loading}>
|
||||
Cancel & Return to Dining
|
||||
<Button type="submit" variant="contained" disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const StaffShiftsView: React.FC = () => {
|
|||
const [staffName, setStaffName] = useState<string>('');
|
||||
const [staffEmail, setStaffEmail] = useState<string>('');
|
||||
const [staffPassword, setStaffPassword] = useState<string>('');
|
||||
const [staffRoleId, setStaffRoleId] = useState<number>(2); // default waiter role_id
|
||||
const [staffRoleId] = useState<number>(2); // default waiter role_id
|
||||
const [staffRole, setStaffRole] = useState<string>('waiter'); // display only
|
||||
const [roles, setRoles] = useState<any[]>([]); // RoleRead[] from backend
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,16 @@ export const SuppliersView: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
// Backend schema: { ingredient_id, quantity, unit_price }
|
||||
setPoItems(prev => [...prev, { ingredient_id: Number(addIngId), quantity: addIngQty, unit_price: addIngPrice }]);
|
||||
setPoItems(prev => [
|
||||
...prev,
|
||||
{
|
||||
ingredient_id: Number(addIngId),
|
||||
quantity: addIngQty,
|
||||
unit_price: addIngPrice,
|
||||
qty: addIngQty,
|
||||
price: addIngPrice,
|
||||
},
|
||||
]);
|
||||
setAddIngId('');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,102 +1,2 @@
|
|||
import React from 'react';
|
||||
import { Chip, Box, Typography } from '@mui/material';
|
||||
import type { OrderStatus, OrderType } from '../../types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status?: OrderStatus;
|
||||
type?: OrderType;
|
||||
dietary?: 'veg' | 'non-veg' | 'jain' | 'vegan';
|
||||
size?: 'small' | 'medium';
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type, dietary, size = 'small' }) => {
|
||||
if (dietary) {
|
||||
const config = {
|
||||
veg: { label: 'VEG', color: '#2e7d32', bg: '#e8f5e9', border: '#2e7d32' },
|
||||
'non-veg': { label: 'NON-VEG', color: '#c62828', bg: '#ffebee', border: '#c62828' },
|
||||
jain: { label: 'JAIN', color: '#ef6c00', bg: '#fff3e0', border: '#ef6c00' },
|
||||
vegan: { label: 'VEGAN', color: '#1565c0', bg: '#e3f2fd', border: '#1565c0' },
|
||||
}[dietary];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
border: `1px solid ${config.border}`,
|
||||
borderRadius: '4px',
|
||||
px: 0.6,
|
||||
py: 0.2,
|
||||
backgroundColor: config.bg,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: config.color,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: config.color,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{config.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
const statusMap = {
|
||||
pending: { label: 'PENDING', color: 'error', bg: '#ffdad6' },
|
||||
preparing: { label: 'PREPARING', color: 'warning', bg: '#ffddba' },
|
||||
ready: { label: 'READY', color: 'success', bg: '#a3f69c' },
|
||||
served: { label: 'SERVED', color: 'default', bg: '#eeeeee' },
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={statusMap.label}
|
||||
size={size}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.7rem',
|
||||
height: size === 'small' ? 22 : 28,
|
||||
}}
|
||||
color={statusMap.color as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
const typeMap = {
|
||||
'Dine-In': { label: 'DINE-IN', color: '#ac2d00', bg: '#ffdbd1' },
|
||||
Takeaway: { label: 'TAKEAWAY', color: '#546067', bg: '#d7e4ec' },
|
||||
Delivery: { label: 'DELIVERY', color: '#845000', bg: '#ffddba' },
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={typeMap.label}
|
||||
size={size}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.7rem',
|
||||
height: size === 'small' ? 22 : 28,
|
||||
backgroundColor: typeMap.bg,
|
||||
color: typeMap.color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
/** Re-export shared badges from @restroai/ui. */
|
||||
export { StatusBadge, DietaryBadge } from '@restroai/ui';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
|
|
@ -9,81 +9,239 @@ import {
|
|||
Divider,
|
||||
Alert,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/WarningAmber';
|
||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import CreditCardIcon from '@mui/icons-material/CreditCard';
|
||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||
import { api } from '../../services/api';
|
||||
import { api, getFrontendMenuItem } from '../../services/api';
|
||||
|
||||
interface BillPaymentViewProps {
|
||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||
}
|
||||
|
||||
type PayOutcome = 'idle' | 'success' | 'failure';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Razorpay?: new (options: Record<string, unknown>) => { open: () => void };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRazorpayScript(): Promise<void> {
|
||||
if (window.Razorpay) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector('script[data-razorpay="1"]');
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () => reject(new Error('Razorpay script failed')));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
|
||||
script.async = true;
|
||||
script.dataset.razorpay = '1';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Razorpay checkout'));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
|
||||
const [splitCount, setSplitCount] = useState<number>(1);
|
||||
const [tipPercentage, setTipPercentage] = useState<number>(10);
|
||||
const [paymentDone, setPaymentDone] = useState<boolean>(false);
|
||||
const [outcome, setOutcome] = useState<PayOutcome>('idle');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'upi' | 'card' | 'cash' | null>(null);
|
||||
const [billItems, setBillItems] = useState<any[]>([]);
|
||||
const [billItems, setBillItems] = useState<
|
||||
{ name: string; qty: number; price: number }[]
|
||||
>([]);
|
||||
const [ticketNum, setTicketNum] = useState<string>('---');
|
||||
const [tableNum, setTableNum] = useState<string>('12');
|
||||
const [billId, setBillId] = useState<string>('');
|
||||
const [billId, setBillId] = useState<number | null>(null);
|
||||
const [billTotal, setBillTotal] = useState<number>(0);
|
||||
const [billSubtotal, setBillSubtotal] = useState<number>(0);
|
||||
const [cgstAmount, setCgstAmount] = useState<number>(0);
|
||||
const [sgstAmount, setSgstAmount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cashHint, setCashHint] = useState(false);
|
||||
|
||||
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
|
||||
const sessionId = sessionStorage.getItem('customer_session_id') || '';
|
||||
|
||||
const loadBill = async () => {
|
||||
const markSuccess = useCallback((method: 'upi' | 'card' | 'cash') => {
|
||||
setPaymentMethod(method);
|
||||
setOutcome('success');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('pay', 'success');
|
||||
if (billId) url.searchParams.set('bill_id', String(billId));
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}, [billId]);
|
||||
|
||||
const markFailure = useCallback((message?: string) => {
|
||||
setOutcome('failure');
|
||||
if (message) setError(message);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('pay', 'failure');
|
||||
if (billId) url.searchParams.set('bill_id', String(billId));
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}, [billId]);
|
||||
|
||||
const loadBill = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payParam = new URLSearchParams(window.location.search).get('pay');
|
||||
if (payParam === 'success') setOutcome('success');
|
||||
if (payParam === 'failure') setOutcome('failure');
|
||||
|
||||
if (!sessionId) {
|
||||
setError('No active table session. Scan the QR code to start.');
|
||||
return;
|
||||
}
|
||||
const order = await api.getOrderForSession(sessionId);
|
||||
if (order) {
|
||||
setTicketNum(order.ticketNumber || '105');
|
||||
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12');
|
||||
if (!order) {
|
||||
setError('No order found for this table yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBillItems(order.items.map((it: any) => ({
|
||||
name: it.name,
|
||||
qty: it.quantity,
|
||||
price: it.price || 150
|
||||
})));
|
||||
setTicketNum(String(order.id ?? '---'));
|
||||
setTableNum(sessionStorage.getItem('customer_table_id') || '12');
|
||||
|
||||
const bill = await api.generateBill(order.id);
|
||||
if (bill) {
|
||||
setBillId(bill.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load bill', e);
|
||||
}
|
||||
const items = (order.items || []).map((it: any) => {
|
||||
const menu = getFrontendMenuItem?.(it.menu_item_id);
|
||||
return {
|
||||
name: it.name || menu?.name || `Dish ${it.menu_item_id}`,
|
||||
qty: Number(it.quantity) || 1,
|
||||
price: Number(it.unit_price ?? it.price ?? menu?.price ?? 0),
|
||||
};
|
||||
});
|
||||
setBillItems(items);
|
||||
|
||||
const bill = await api.sessionGenerateBill(order.id);
|
||||
if (bill) {
|
||||
setBillId(Number(bill.id));
|
||||
setBillSubtotal(Number(bill.subtotal));
|
||||
setCgstAmount(Number(bill.cgst_amount));
|
||||
setSgstAmount(Number(bill.sgst_amount));
|
||||
setBillTotal(Number(bill.total_amount));
|
||||
if (bill.status === 'paid') {
|
||||
setOutcome('success');
|
||||
setPaymentMethod('upi');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load bill', e);
|
||||
setError(e?.message || 'Failed to load bill');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBill();
|
||||
}, []);
|
||||
}, [loadBill]);
|
||||
|
||||
const subtotal = billItems.reduce((acc, i) => acc + i.price * i.qty, 0);
|
||||
const cgst = subtotal * 0.025;
|
||||
const sgst = subtotal * 0.025;
|
||||
const serviceCharge = subtotal * 0.05;
|
||||
const tip = subtotal * (tipPercentage / 100);
|
||||
const totalBill = subtotal + cgst + sgst + serviceCharge + tip;
|
||||
const perPersonPrice = totalBill / splitCount;
|
||||
const tip = billSubtotal * (tipPercentage / 100);
|
||||
const displayTotal = billTotal + tip;
|
||||
const perPersonPrice = displayTotal / splitCount;
|
||||
|
||||
const handlePay = async (method: 'upi' | 'card' | 'cash') => {
|
||||
try {
|
||||
if (billId) {
|
||||
await api.recordPayment(billId, method, totalBill);
|
||||
const confirmMockCheckout = async (
|
||||
checkout: any,
|
||||
method: 'upi' | 'card'
|
||||
) => {
|
||||
await api.sessionConfirmRazorpay(checkout.bill_id, {
|
||||
razorpay_order_id: checkout.razorpay_order_id,
|
||||
razorpay_payment_id: `pay_mock_${Date.now()}`,
|
||||
razorpay_signature: 'mock',
|
||||
amount: Number(checkout.amount),
|
||||
method,
|
||||
});
|
||||
markSuccess(method);
|
||||
};
|
||||
|
||||
const openLiveCheckout = async (checkout: any, method: 'upi' | 'card') => {
|
||||
await loadRazorpayScript();
|
||||
if (!window.Razorpay) {
|
||||
throw new Error('Razorpay checkout unavailable');
|
||||
}
|
||||
setPaymentMethod(method);
|
||||
setPaymentDone(true);
|
||||
} catch (e) {
|
||||
alert('Failed to record payment: ' + e);
|
||||
const amountPaise = Math.round(Number(checkout.amount) * 100);
|
||||
const rzp = new window.Razorpay({
|
||||
key: checkout.key_id,
|
||||
amount: amountPaise,
|
||||
currency: checkout.currency || 'INR',
|
||||
name: 'RestroAI',
|
||||
description: `Bill #${checkout.bill_id}`,
|
||||
order_id: checkout.razorpay_order_id,
|
||||
handler: async (response: {
|
||||
razorpay_order_id: string;
|
||||
razorpay_payment_id: string;
|
||||
razorpay_signature: string;
|
||||
}) => {
|
||||
try {
|
||||
await api.sessionConfirmRazorpay(checkout.bill_id, {
|
||||
razorpay_order_id: response.razorpay_order_id,
|
||||
razorpay_payment_id: response.razorpay_payment_id,
|
||||
razorpay_signature: response.razorpay_signature,
|
||||
amount: Number(checkout.amount),
|
||||
method,
|
||||
});
|
||||
markSuccess(method);
|
||||
} catch (err: any) {
|
||||
markFailure(err?.message || 'Payment confirmation failed');
|
||||
}
|
||||
},
|
||||
modal: {
|
||||
ondismiss: () => {
|
||||
markFailure('Payment cancelled');
|
||||
},
|
||||
},
|
||||
theme: { color: '#ac2d00' },
|
||||
});
|
||||
rzp.open();
|
||||
};
|
||||
|
||||
const handleGatewayPay = async (method: 'upi' | 'card') => {
|
||||
if (!billId) {
|
||||
setError('Bill not ready yet');
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
setError(null);
|
||||
setCashHint(false);
|
||||
try {
|
||||
const checkout = await api.sessionCheckoutRazorpay(billId);
|
||||
if (checkout.mock) {
|
||||
await confirmMockCheckout(checkout, method);
|
||||
} else {
|
||||
await openLiveCheckout(checkout, method);
|
||||
}
|
||||
} catch (e: any) {
|
||||
markFailure(e?.message || 'Checkout failed');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCash = () => {
|
||||
setCashHint(true);
|
||||
setPaymentMethod('cash');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '60vh', display: 'grid', placeItems: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
|
|
@ -104,19 +262,26 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
Bill & Payment
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||
🪑 Table {tableNum} · Ticket #{ticketNum}
|
||||
Table {tableNum} · Order #{ticketNum}
|
||||
{billId ? ` · Bill #${billId}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={<ReceiptLongIcon fontSize="small" />}
|
||||
label={paymentDone ? 'PAID ✓' : 'UNPAID'}
|
||||
color={paymentDone ? 'success' : 'error'}
|
||||
label={outcome === 'success' ? 'PAID ✓' : outcome === 'failure' ? 'FAILED' : 'UNPAID'}
|
||||
color={outcome === 'success' ? 'success' : outcome === 'failure' ? 'warning' : 'error'}
|
||||
sx={{ fontWeight: 800 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Container maxWidth="sm" sx={{ pt: 3 }}>
|
||||
{paymentDone ? (
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: '12px' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{outcome === 'success' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
|
|
@ -132,10 +297,13 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
Payment Successful!
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}>
|
||||
₹{totalBill.toFixed(0)} paid via {paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}
|
||||
₹{billTotal.toFixed(0)} paid
|
||||
{paymentMethod
|
||||
? ` via ${paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}`
|
||||
: ''}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}>
|
||||
Thank you for dining with RestroAI! Your receipt has been sent.
|
||||
Thank you for dining with RestroAI!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -147,16 +315,61 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
Back to Home
|
||||
</Button>
|
||||
</Paper>
|
||||
) : outcome === 'failure' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
borderRadius: '24px',
|
||||
border: '2px solid #ffccbc',
|
||||
bgcolor: '#fff8f6',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineIcon sx={{ fontSize: 64, color: '#ac2d00', mb: 2 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00', mb: 1 }}>
|
||||
Payment not completed
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 3 }}>
|
||||
You can try again or ask staff for help.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setOutcome('idle');
|
||||
setError(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('pay');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}}
|
||||
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800, bgcolor: '#ac2d00' }}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
{/* Itemized Bill */}
|
||||
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #f0ebe7',
|
||||
bgcolor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
||||
🧾 Bill Details
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
GSTIN: 29XXXXXX0001Z5
|
||||
Bill Details
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -174,32 +387,48 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{[
|
||||
{ label: 'Subtotal', value: `₹${subtotal.toFixed(0)}` },
|
||||
{ label: 'CGST (2.5%)', value: `₹${cgst.toFixed(0)}` },
|
||||
{ label: 'SGST (2.5%)', value: `₹${sgst.toFixed(0)}` },
|
||||
{ label: 'Service Charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
||||
{ label: `Staff Tip (${tipPercentage}%)`, value: `₹${tip.toFixed(0)}` },
|
||||
{ label: 'Subtotal', value: `₹${billSubtotal.toFixed(0)}` },
|
||||
{ label: 'CGST', value: `₹${cgstAmount.toFixed(0)}` },
|
||||
{ label: 'SGST', value: `₹${sgstAmount.toFixed(0)}` },
|
||||
{ label: `Suggested tip (${tipPercentage}%)`, value: `₹${tip.toFixed(0)}` },
|
||||
].map((row) => (
|
||||
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Total Amount</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
|
||||
Pay now
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||
₹{totalBill.toFixed(0)}
|
||||
₹{billTotal.toFixed(0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||
Gateway charges bill total (tip is optional / cash).
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Split Bill */}
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #f0ebe7',
|
||||
bgcolor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||
👥 Split the Bill
|
||||
Split the Bill
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
{[1, 2, 3, 4].map((count) => (
|
||||
|
|
@ -217,15 +446,23 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
</Box>
|
||||
{splitCount > 1 && (
|
||||
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
|
||||
Each person pays: ₹{perPersonPrice.toFixed(0)}
|
||||
Each person ≈ ₹{perPersonPrice.toFixed(0)} (incl. suggested tip)
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Tip Selector */}
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #f0ebe7',
|
||||
bgcolor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||
❤️ Add Tip for Staff
|
||||
Suggested tip
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{[0, 10, 15, 20].map((pct) => (
|
||||
|
|
@ -242,19 +479,33 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Payment Options */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#8f7068', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{cashHint && (
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: '12px', fontWeight: 700 }}>
|
||||
Please pay cash to your waiter. They will mark the bill paid at the counter.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: '#8f7068',
|
||||
mb: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
Choose Payment Method
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* UPI Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => handlePay('upi')}
|
||||
startIcon={<QrCode2Icon />}
|
||||
disabled={paying || !billId}
|
||||
onClick={() => handleGatewayPay('upi')}
|
||||
startIcon={paying ? <CircularProgress size={18} color="inherit" /> : <QrCode2Icon />}
|
||||
sx={{
|
||||
py: 1.8,
|
||||
borderRadius: '14px',
|
||||
|
|
@ -264,7 +515,7 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
'&:hover': { bgcolor: '#38006b' },
|
||||
}}
|
||||
>
|
||||
Pay ₹{totalBill.toFixed(0)} via UPI / GPay / PhonePe
|
||||
Pay ₹{billTotal.toFixed(0)} via UPI / GPay / PhonePe
|
||||
</Button>
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
|
|
@ -273,9 +524,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={() => handlePay('card')}
|
||||
disabled={paying || !billId}
|
||||
onClick={() => handleGatewayPay('card')}
|
||||
startIcon={<CreditCardIcon />}
|
||||
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
fontWeight: 800,
|
||||
color: '#ac2d00',
|
||||
borderColor: '#ac2d00',
|
||||
}}
|
||||
>
|
||||
Debit / Credit Card
|
||||
</Button>
|
||||
|
|
@ -285,9 +543,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
|||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={() => handlePay('cash')}
|
||||
disabled={paying}
|
||||
onClick={handleCash}
|
||||
startIcon={<CurrencyRupeeIcon />}
|
||||
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#2e7d32', borderColor: '#2e7d32' }}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
fontWeight: 800,
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
}}
|
||||
>
|
||||
Pay by Cash
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,6 +11,9 @@ import {
|
|||
Chip,
|
||||
Card,
|
||||
CardMedia,
|
||||
BottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
|
@ -18,41 +21,65 @@ import RemoveIcon from '@mui/icons-material/Remove';
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
|
||||
import { MENU_ITEMS } from '../../data/menuData';
|
||||
import type { MenuItem } from '../../data/menuData';
|
||||
import { StatusBadge } from '../common/StatusBadge';
|
||||
|
||||
interface CartReviewViewProps {
|
||||
cart: { [itemId: string]: number };
|
||||
cartNotes?: { [itemId: string]: string };
|
||||
menuItems?: MenuItem[];
|
||||
onUpdateCart: (itemId: string, quantity: number) => void;
|
||||
onPlaceOrder: (orderNotes: string) => void;
|
||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||
}
|
||||
|
||||
const tableLabel = () =>
|
||||
sessionStorage.getItem('customer_table_number') ||
|
||||
sessionStorage.getItem('customer_table_id') ||
|
||||
'—';
|
||||
|
||||
export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||
cart,
|
||||
cartNotes = {},
|
||||
menuItems = MENU_ITEMS,
|
||||
onUpdateCart,
|
||||
onPlaceOrder,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const [orderNotes, setOrderNotes] = useState<string>('');
|
||||
const [placing, setPlacing] = useState(false);
|
||||
|
||||
const cartEntries = Object.entries(cart)
|
||||
.map(([id, qty]) => {
|
||||
const item = MENU_ITEMS.find((m) => m.id === id);
|
||||
return item ? { item, qty } : null;
|
||||
const item = menuItems.find((m) => String(m.id) === id);
|
||||
return item ? { item, qty, notes: cartNotes[id] } : null;
|
||||
})
|
||||
.filter(Boolean) as { item: MenuItem; qty: number }[];
|
||||
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[];
|
||||
|
||||
const totalUnits = cartEntries.reduce((sum, e) => sum + e.qty, 0);
|
||||
const subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
|
||||
const cgst = subtotal * 0.025; // CGST 2.5%
|
||||
const sgst = subtotal * 0.025; // SGST 2.5%
|
||||
const serviceCharge = subtotal * 0.05; // 5% service charge
|
||||
const cgst = subtotal * 0.025;
|
||||
const sgst = subtotal * 0.025;
|
||||
const serviceCharge = subtotal * 0.05;
|
||||
const grandTotal = subtotal + cgst + sgst + serviceCharge;
|
||||
|
||||
const handlePlace = async () => {
|
||||
if (placing || cartEntries.length === 0) return;
|
||||
setPlacing(true);
|
||||
try {
|
||||
await Promise.resolve(onPlaceOrder(orderNotes));
|
||||
} finally {
|
||||
setPlacing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: cartEntries.length ? 22 : 12 }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
|
|
@ -68,22 +95,22 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton onClick={() => onNavigate('menu')} size="small">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<IconButton onClick={() => onNavigate('menu')} size="small" aria-label="Back to menu">
|
||||
<ArrowBackIcon sx={{ color: '#ac2d00' }} />
|
||||
</IconButton>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
||||
Your Cart
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.15 }}>
|
||||
Review order
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'}
|
||||
Table {tableLabel()} · Check items before placing
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${cartEntries.length} item${cartEntries.length !== 1 ? 's' : ''}`}
|
||||
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00' }}
|
||||
label={`${totalUnits} item${totalUnits === 1 ? '' : 's'}`}
|
||||
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00', flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
|
@ -93,22 +120,29 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
|||
elevation={0}
|
||||
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }}
|
||||
>
|
||||
<Typography sx={{ fontSize: '2.5rem', mb: 1 }}>🛒</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
|
||||
Your cart is empty
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}>
|
||||
Go back and add some delicious dishes!
|
||||
Browse the menu and add dishes. You can check your cart anytime while ordering.
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" onClick={() => onNavigate('menu')}
|
||||
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}>
|
||||
Browse Menu
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => onNavigate('menu')}
|
||||
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
|
||||
>
|
||||
Browse menu
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
{/* Cart Items */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, px: 0.5 }}>
|
||||
Items in your cart
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
|
||||
{cartEntries.map(({ item, qty }) => (
|
||||
{cartEntries.map(({ item, qty, notes }) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
elevation={0}
|
||||
|
|
@ -129,43 +163,84 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
|||
alt={item.name}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<StatusBadge dietary={item.dietary} />
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', my: 0.5 }}>
|
||||
₹{(item.price * qty).toFixed(0)}
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
||||
₹{item.price} each
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{notes ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: '#845000',
|
||||
bgcolor: '#fff8ee',
|
||||
borderRadius: 1,
|
||||
px: 0.75,
|
||||
py: 0.35,
|
||||
mt: 0.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#f8f5f2', borderRadius: '9999px' }}>
|
||||
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty - 1)}>
|
||||
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty - 1)}>
|
||||
<RemoveIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<Typography variant="body2" sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00', minWidth: 20, textAlign: 'center' }}
|
||||
>
|
||||
{qty}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty + 1)}>
|
||||
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty + 1)}>
|
||||
<AddIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<IconButton size="small" color="error" onClick={() => onUpdateCart(item.id, 0)}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 900, color: '#ac2d00' }}>₹{(item.price * qty).toFixed(0)}</Typography>
|
||||
<IconButton size="small" color="error" onClick={() => onUpdateCart(String(item.id), 0)} aria-label="Remove">
|
||||
<DeleteIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Instructions */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => onNavigate('menu')}
|
||||
startIcon={<AddIcon />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
borderRadius: '12px',
|
||||
fontWeight: 800,
|
||||
py: 1.25,
|
||||
color: '#ac2d00',
|
||||
borderColor: '#e4ddd8',
|
||||
borderStyle: 'dashed',
|
||||
'&:hover': { borderColor: '#ac2d00', bgcolor: '#fff8f5' },
|
||||
}}
|
||||
>
|
||||
Add more items
|
||||
</Button>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||
📝 Special Instructions
|
||||
Special instructions (optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
placeholder="E.g. Extra spicy, no onion, serve starters first..."
|
||||
placeholder="E.g. Extra spicy, no onion, serve starters first…"
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
|
|
@ -176,60 +251,125 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
|||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Bill Summary */}
|
||||
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
||||
Bill Summary
|
||||
Bill summary
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{[
|
||||
{ label: 'Items Total', value: `₹${subtotal.toFixed(0)}` },
|
||||
{ label: 'Items total', value: `₹${subtotal.toFixed(0)}` },
|
||||
{ label: 'CGST (2.5%)', value: `₹${cgst.toFixed(0)}` },
|
||||
{ label: 'SGST (2.5%)', value: `₹${sgst.toFixed(0)}` },
|
||||
{ label: 'Service Charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
||||
{ label: 'Service charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
||||
].map((row) => (
|
||||
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6 }}>
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
|
||||
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.55 }}>
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Grand Total</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>₹{grandTotal.toFixed(0)}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
|
||||
To pay
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||
₹{grandTotal.toFixed(0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}>
|
||||
*Inclusive of all taxes as per GST regulations
|
||||
Inclusive of taxes · Review carefully before placing
|
||||
</Typography>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{/* Place Order CTA */}
|
||||
{cartEntries.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 64,
|
||||
zIndex: 120,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
bgcolor: 'rgba(248,245,242,0.92)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderTop: '1px solid #f0ebe7',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm" disableGutters>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => onPlaceOrder(orderNotes)}
|
||||
disabled={placing}
|
||||
onClick={() => void handlePlace()}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{
|
||||
py: 2,
|
||||
py: 1.6,
|
||||
borderRadius: '9999px',
|
||||
fontSize: '1.05rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 900,
|
||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
|
||||
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
|
||||
}}
|
||||
>
|
||||
Place Order — ₹{grandTotal.toFixed(0)}
|
||||
{placing ? 'Placing order…' : `Place order · ₹${grandTotal.toFixed(0)}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
borderTop: '1px solid #f0ebe7',
|
||||
}}
|
||||
>
|
||||
<BottomNavigation
|
||||
value={2}
|
||||
onChange={(_, v) => {
|
||||
if (v === 0) onNavigate('landing');
|
||||
if (v === 1) onNavigate('menu');
|
||||
if (v === 3) onNavigate('status');
|
||||
}}
|
||||
sx={{ bgcolor: '#ffffff', height: 64 }}
|
||||
>
|
||||
<BottomNavigationAction label="Home" icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||
<BottomNavigationAction label="Menu" icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||
<BottomNavigationAction
|
||||
label="Cart"
|
||||
icon={
|
||||
<Badge badgeContent={totalUnits} color="error">
|
||||
<ShoppingCartIcon />
|
||||
</Badge>
|
||||
}
|
||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Status"
|
||||
icon={<TrackChangesIcon />}
|
||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
</BottomNavigation>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export const CustomizationModal: React.FC<CustomizationModalProps> = ({
|
|||
const handleAdd = () => {
|
||||
const formattedNotes = [
|
||||
`Spice: ${spiceLevel}`,
|
||||
extraCheese ? 'Extra Cheese (+ $2.50)' : '',
|
||||
extraTruffle ? 'Extra Truffle Oil (+ $3.00)' : '',
|
||||
extraCheese ? 'Extra Cheese (+₹40)' : '',
|
||||
extraTruffle ? 'Extra Truffle Oil (+₹60)' : '',
|
||||
specialNotes ? `Note: ${specialNotes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
BottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
|
|
@ -27,9 +29,13 @@ import StarIcon from '@mui/icons-material/Star';
|
|||
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
|
||||
import type { MenuItem, CategoryType } from '../../data/menuData';
|
||||
import { StatusBadge } from '../common/StatusBadge';
|
||||
import { useLocale } from '../../i18n/LocaleContext';
|
||||
import { CartPeekDrawer } from './CartPeekDrawer';
|
||||
|
||||
interface MenuBrowseViewProps {
|
||||
cart: { [itemId: string]: number };
|
||||
cartNotes?: { [itemId: string]: string };
|
||||
menuItems?: MenuItem[];
|
||||
onUpdateCart: (itemId: string, quantity: number) => void;
|
||||
onOpenCustomization: (item: MenuItem) => void;
|
||||
onOpenVoice: () => void;
|
||||
|
|
@ -50,26 +56,49 @@ const SPICE_ICONS: Record<string, string> = {
|
|||
'extra-hot': '🔥',
|
||||
};
|
||||
|
||||
const tableLabel = () =>
|
||||
sessionStorage.getItem('customer_table_number') ||
|
||||
sessionStorage.getItem('customer_table_id') ||
|
||||
'—';
|
||||
|
||||
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||
cart,
|
||||
cartNotes = {},
|
||||
menuItems = MENU_ITEMS,
|
||||
onUpdateCart,
|
||||
onOpenCustomization,
|
||||
onOpenVoice,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
|
||||
const [bottomNav, setBottomNav] = useState(1); // 1 = Menu
|
||||
const [bottomNav, setBottomNav] = useState(1);
|
||||
const [cartPeekOpen, setCartPeekOpen] = useState(false);
|
||||
const [emptyHint, setEmptyHint] = useState(false);
|
||||
|
||||
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
|
||||
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
|
||||
const item = MENU_ITEMS.find((m) => m.id === id);
|
||||
const item = menuItems.find((m) => String(m.id) === id);
|
||||
return sum + (item ? item.price * qty : 0);
|
||||
}, 0);
|
||||
|
||||
const cartLines = useMemo(
|
||||
() =>
|
||||
Object.entries(cart)
|
||||
.map(([id, qty]) => {
|
||||
const item = menuItems.find((m) => String(m.id) === id);
|
||||
if (!item) return null;
|
||||
return { item, qty, notes: cartNotes[id] };
|
||||
})
|
||||
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[],
|
||||
[cart, cartNotes, menuItems]
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return MENU_ITEMS.filter((item) => {
|
||||
return menuItems.filter((item) => {
|
||||
if (item.isAvailable === false) return false;
|
||||
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
|
||||
const matchSearch =
|
||||
!searchQuery ||
|
||||
|
|
@ -79,20 +108,31 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
|
||||
return matchCategory && matchSearch && matchDietary;
|
||||
});
|
||||
}, [activeCategory, searchQuery, dietaryFilter]);
|
||||
}, [menuItems, activeCategory, searchQuery, dietaryFilter]);
|
||||
|
||||
const openCartCheck = () => {
|
||||
setCartPeekOpen(true);
|
||||
};
|
||||
|
||||
const handleCartTap = () => {
|
||||
openCartCheck();
|
||||
if (totalCartItems === 0) setEmptyHint(true);
|
||||
};
|
||||
|
||||
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setBottomNav(newValue);
|
||||
if (newValue === 0) onNavigate('landing');
|
||||
if (newValue === 1) setBottomNav(1);
|
||||
if (newValue === 2) {
|
||||
if (totalCartItems > 0) onNavigate('cart');
|
||||
openCartCheck();
|
||||
if (totalCartItems === 0) setEmptyHint(true);
|
||||
setBottomNav(1);
|
||||
}
|
||||
if (newValue === 3) onNavigate('status');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Sticky Header */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
|
|
@ -103,30 +143,27 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
|
||||
RestroAI Menu
|
||||
Menu
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
||||
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'} · Dine-In
|
||||
Table {tableLabel()} · Tap dishes to add · Check cart anytime
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
<IconButton
|
||||
onClick={onOpenVoice}
|
||||
sx={{
|
||||
bgcolor: '#ffdbd1',
|
||||
color: '#ac2d00',
|
||||
'&:hover': { bgcolor: '#ffcab8' },
|
||||
}}
|
||||
aria-label="Voice order"
|
||||
sx={{ bgcolor: '#ffdbd1', color: '#ac2d00', '&:hover': { bgcolor: '#ffcab8' } }}
|
||||
>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
<Badge badgeContent={totalCartItems} color="error">
|
||||
<IconButton
|
||||
onClick={() => totalCartItems > 0 && onNavigate('cart')}
|
||||
onClick={handleCartTap}
|
||||
aria-label="View cart"
|
||||
sx={{
|
||||
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
|
||||
color: totalCartItems > 0 ? '#fff' : '#8f7068',
|
||||
|
|
@ -139,12 +176,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Box sx={{ px: 2, pb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search dishes, e.g. biryani, paneer..."
|
||||
placeholder="Search dishes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
slotProps={{
|
||||
|
|
@ -166,21 +202,26 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
/>
|
||||
</Box>
|
||||
|
||||
{/* Category Tabs - Horizontal Scroll */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.8,
|
||||
px: 2,
|
||||
pb: 1.5,
|
||||
pb: 1,
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const catEmoji: Record<string, string> = {
|
||||
All: '🍽️', Starters: '🥗', Biryani: '🍚', Mains: '🍛',
|
||||
Breads: '🫓', Drinks: '🥤', Desserts: '🍮', Specials: '⭐',
|
||||
All: '🍽️',
|
||||
Starters: '🥗',
|
||||
Biryani: '🍚',
|
||||
Mains: '🍛',
|
||||
Breads: '🫓',
|
||||
Drinks: '🥤',
|
||||
Desserts: '🍮',
|
||||
Specials: '⭐',
|
||||
};
|
||||
const isActive = activeCategory === cat;
|
||||
return (
|
||||
|
|
@ -195,15 +236,13 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
color: isActive ? '#fff' : '#5b4139',
|
||||
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
|
||||
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Dietary Filters */}
|
||||
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.5, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.25, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
{DIETARY_FILTERS.map((f) => (
|
||||
<Chip
|
||||
key={f.label}
|
||||
|
|
@ -218,19 +257,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* AI Banner */}
|
||||
<Box sx={{ px: 2, pt: 2 }}>
|
||||
<Box sx={{ px: 2, pt: 1.5 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
p: 1.75,
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
mb: 1.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onOpenVoice}
|
||||
|
|
@ -249,163 +287,140 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
>
|
||||
<AutoAwesomeIcon />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
🍛 Today's AI Pick: Hyderabadi Chicken Biryani
|
||||
Order with AI voice
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
Tap to voice order or browse below · ₹380
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Say what you want — we add it to your cart
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Menu Cards */}
|
||||
<Box sx={{ flex: 1, px: 2, pb: 20 }}>
|
||||
<Box sx={{ flex: 1, px: 2, pb: totalCartItems > 0 ? 22 : 14 }}>
|
||||
{activeCategory !== 'All' && (
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{activeCategory} ({filteredItems.length} items)
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||
>
|
||||
{activeCategory} ({filteredItems.length})
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>No dishes found</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#b0a09a' }}>Try a different search or filter</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
||||
No dishes found
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 2 }}>
|
||||
Try a different search or filter
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setDietaryFilter(null);
|
||||
setActiveCategory('All');
|
||||
}}
|
||||
sx={{ borderRadius: '9999px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{filteredItems.map((item) => {
|
||||
const qty = cart[item.id] || 0;
|
||||
const qty = cart[String(item.id)] || 0;
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
border: '1px solid #f0ebe7',
|
||||
borderRadius: '16px',
|
||||
border: qty > 0 ? '1.5px solid #ac2d00' : '1px solid #f0ebe7',
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
transition: 'box-shadow 0.2s ease',
|
||||
'&:hover': { boxShadow: '0 6px 20px rgba(0,0,0,0.08)' },
|
||||
display: 'flex',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* Food Image */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box sx={{ position: 'relative', width: 112, flexShrink: 0 }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="180"
|
||||
image={item.image}
|
||||
alt={item.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
sx={{ width: 112, height: '100%', minHeight: 112, objectFit: 'cover' }}
|
||||
/>
|
||||
{/* Badges on image */}
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{item.isChefSpecial && (
|
||||
{(item.isChefSpecial || item.isBestseller) && (
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon sx={{ fontSize: '14px !important' }} />}
|
||||
label="Chef's Special"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(172,45,0,0.9)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.65rem',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.isBestseller && (
|
||||
<Chip
|
||||
icon={<StarIcon sx={{ fontSize: '14px !important', color: '#FFD700' }} />}
|
||||
label="Bestseller"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(0,0,0,0.7)',
|
||||
color: '#FFD700',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.65rem',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{item.spiceLevel && (
|
||||
<Chip
|
||||
label={`${SPICE_ICONS[item.spiceLevel]} ${item.spiceLevel}`}
|
||||
icon={
|
||||
item.isChefSpecial ? (
|
||||
<AutoAwesomeIcon sx={{ fontSize: '12px !important' }} />
|
||||
) : (
|
||||
<StarIcon sx={{ fontSize: '12px !important', color: '#FFD700' }} />
|
||||
)
|
||||
}
|
||||
label={item.isChefSpecial ? 'Special' : 'Hit'}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
textTransform: 'capitalize',
|
||||
top: 6,
|
||||
left: 6,
|
||||
height: 22,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 800,
|
||||
bgcolor: 'rgba(0,0,0,0.7)',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Card Content */}
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||
<Box sx={{ flex: 1, mr: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
|
||||
<Box sx={{ p: 1.5, flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, mb: 0.35 }}>
|
||||
<Typography sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.25, fontSize: '0.95rem' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.nameHindi && (
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
||||
{item.nameHindi}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<StatusBadge dietary={item.dietary} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 1.5, lineHeight: 1.5, fontSize: '0.8rem' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#6b5c57',
|
||||
mb: 1,
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</Typography>
|
||||
|
||||
{/* Tags */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.5 }}>
|
||||
{item.tags.slice(0, 3).map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.62rem', fontWeight: 700, bgcolor: '#f8f5f2', height: 20 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price + Action Row */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||
₹{item.price}
|
||||
</Typography>
|
||||
<Typography sx={{ fontWeight: 900, color: '#ac2d00', fontSize: '1rem' }}>₹{item.price}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||
⏱ {item.prepTimeMinutes} min
|
||||
{item.spiceLevel ? `${SPICE_ICONS[item.spiceLevel] || ''} ` : ''}
|
||||
{item.prepTimeMinutes} min
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{qty === 0 ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => onOpenCustomization(item)}
|
||||
sx={{
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 800,
|
||||
color: '#ac2d00',
|
||||
borderColor: '#ac2d00',
|
||||
px: 2,
|
||||
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
|
||||
bgcolor: '#ac2d00',
|
||||
px: 1.75,
|
||||
boxShadow: 'none',
|
||||
'&:hover': { bgcolor: '#872100', boxShadow: 'none' },
|
||||
}}
|
||||
>
|
||||
Add
|
||||
|
|
@ -422,18 +437,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onUpdateCart(item.id, qty - 1)}
|
||||
sx={{ color: '#fff', p: 0.8 }}
|
||||
onClick={() => onUpdateCart(String(item.id), qty - 1)}
|
||||
sx={{ color: '#fff', p: 0.7 }}
|
||||
>
|
||||
<RemoveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography sx={{ px: 1.5, color: '#fff', fontWeight: 900, fontFamily: 'monospace' }}>
|
||||
<Typography sx={{ px: 1.25, color: '#fff', fontWeight: 900, minWidth: 20, textAlign: 'center' }}>
|
||||
{qty}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onUpdateCart(item.id, qty + 1)}
|
||||
sx={{ color: '#fff', p: 0.8 }}
|
||||
onClick={() => onUpdateCart(String(item.id), qty + 1)}
|
||||
sx={{ color: '#fff', p: 0.7 }}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
|
@ -448,53 +463,66 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
)}
|
||||
</Box>
|
||||
|
||||
{/* Floating Cart Pill */}
|
||||
{totalCartItems > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 72,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
left: 12,
|
||||
right: 12,
|
||||
zIndex: 200,
|
||||
maxWidth: 520,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
onClick={() => onNavigate('cart')}
|
||||
elevation={10}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: '9999px',
|
||||
bgcolor: '#ac2d00',
|
||||
alignItems: 'stretch',
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#1a1c1c',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 28px rgba(172,45,0,0.45)',
|
||||
'&:hover': { bgcolor: '#872100' },
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: 260,
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: '0 10px 28px rgba(0,0,0,0.28)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Badge badgeContent={totalCartItems} color="warning" sx={{ '& .MuiBadge-badge': { fontWeight: 800 } }}>
|
||||
<ShoppingCartIcon />
|
||||
</Badge>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{totalCartItems} item{totalCartItems > 1 ? 's' : ''} in cart
|
||||
<Box
|
||||
onClick={openCartCheck}
|
||||
sx={{
|
||||
flex: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ opacity: 0.8, fontWeight: 700 }}>
|
||||
View cart · {totalCartItems} item{totalCartItems === 1 ? '' : 's'}
|
||||
</Typography>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: '1.05rem' }}>₹{totalCartValue.toFixed(0)}</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>
|
||||
₹{totalCartValue.toFixed(0)} →
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => onNavigate('cart')}
|
||||
sx={{
|
||||
px: 2.5,
|
||||
borderRadius: 0,
|
||||
bgcolor: '#ac2d00',
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': { bgcolor: '#872100' },
|
||||
}}
|
||||
>
|
||||
Checkout →
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
|
|
@ -506,23 +534,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
borderTop: '1px solid #f0ebe7',
|
||||
}}
|
||||
>
|
||||
<BottomNavigation
|
||||
value={bottomNav}
|
||||
onChange={handleBottomNav}
|
||||
sx={{ bgcolor: '#ffffff', height: 64 }}
|
||||
>
|
||||
<BottomNavigation value={bottomNav} onChange={handleBottomNav} sx={{ bgcolor: '#ffffff', height: 64 }}>
|
||||
<BottomNavigationAction label={t('brand')} icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||
<BottomNavigationAction label={t('menu')} icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||
<BottomNavigationAction
|
||||
label="Home"
|
||||
icon={<HomeIcon />}
|
||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Menu"
|
||||
icon={<MenuBookIcon />}
|
||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Cart"
|
||||
label={t('cart')}
|
||||
icon={
|
||||
<Badge badgeContent={totalCartItems} color="error">
|
||||
<ShoppingCartIcon />
|
||||
|
|
@ -531,12 +547,37 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="My Order"
|
||||
label={t('orderStatus')}
|
||||
icon={<TrackChangesIcon />}
|
||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||
/>
|
||||
</BottomNavigation>
|
||||
</Paper>
|
||||
|
||||
<CartPeekDrawer
|
||||
open={cartPeekOpen}
|
||||
lines={cartLines}
|
||||
subtotal={totalCartValue}
|
||||
onClose={() => setCartPeekOpen(false)}
|
||||
onUpdateCart={onUpdateCart}
|
||||
onContinueShopping={() => setCartPeekOpen(false)}
|
||||
onCheckout={() => {
|
||||
setCartPeekOpen(false);
|
||||
onNavigate('cart');
|
||||
}}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={emptyHint}
|
||||
autoHideDuration={2800}
|
||||
onClose={() => setEmptyHint(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
sx={{ bottom: { xs: 140, sm: 140 } }}
|
||||
>
|
||||
<Alert severity="info" onClose={() => setEmptyHint(false)} sx={{ fontWeight: 700, borderRadius: '12px' }}>
|
||||
Cart is empty — add a dish, then check it here anytime.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,15 @@ import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
|||
import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt';
|
||||
import { api, RestroWebSocket, subscribeToWsEvents } from '../../services/api';
|
||||
|
||||
interface PlacedItem {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface OrderStatusViewProps {
|
||||
placedItems?: PlacedItem[];
|
||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||
}
|
||||
|
||||
|
|
@ -53,14 +61,34 @@ const ORDER_STEPS = [
|
|||
},
|
||||
];
|
||||
|
||||
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate }) => {
|
||||
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({
|
||||
placedItems = [],
|
||||
onNavigate,
|
||||
}) => {
|
||||
const [activeStep, setActiveStep] = useState<number>(0);
|
||||
const [ticketNum, setTicketNum] = useState<string>('---');
|
||||
const [tableNum, setTableNum] = useState<string>('12');
|
||||
const [tableNum, setTableNum] = useState<string>(
|
||||
() =>
|
||||
sessionStorage.getItem('customer_table_number') ||
|
||||
sessionStorage.getItem('customer_table_id') ||
|
||||
'—'
|
||||
);
|
||||
const [waiterNotified, setWaiterNotified] = useState<boolean>(false);
|
||||
const [eta, setEta] = useState(15);
|
||||
|
||||
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
|
||||
const summaryItems =
|
||||
placedItems.length > 0
|
||||
? placedItems
|
||||
: (() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('customer_last_order_items');
|
||||
return raw ? (JSON.parse(raw) as PlacedItem[]) : [];
|
||||
} catch {
|
||||
return [] as PlacedItem[];
|
||||
}
|
||||
})();
|
||||
const summaryTotal = summaryItems.reduce((s, i) => s + i.qty * i.price, 0);
|
||||
|
||||
const loadOrder = async () => {
|
||||
try {
|
||||
|
|
@ -211,33 +239,54 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
|
|||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
{/* Ordered Items Summary */}
|
||||
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1.5 }}>
|
||||
🧾 Your Order Summary
|
||||
Your order summary
|
||||
</Typography>
|
||||
{[
|
||||
{ name: 'Hyderabadi Chicken Biryani', qty: 1, price: 380 },
|
||||
{ name: 'Paneer Tikka', qty: 1, price: 280 },
|
||||
{ name: 'Garlic Naan', qty: 2, price: 80 },
|
||||
{ name: 'Mango Lassi', qty: 2, price: 120 },
|
||||
].map((item, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6, borderBottom: idx < 3 ? '1px solid #f5f0ec' : 'none' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{summaryItems.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#8f7068' }}>
|
||||
Order details will appear here after you place an order from the cart.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{summaryItems.map((item, idx) => (
|
||||
<Box
|
||||
key={`${item.name}-${idx}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
py: 0.75,
|
||||
borderBottom: idx < summaryItems.length - 1 ? '1px solid #f5f0ec' : 'none',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{item.qty}× {item.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||
{item.notes ? (
|
||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||
{item.notes}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', flexShrink: 0 }}>
|
||||
₹{item.qty * item.price}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, pt: 1, borderTop: '2px solid #f0ebe7' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Estimated Total</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>₹960</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>
|
||||
Items total
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||
₹{summaryTotal.toFixed(0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
|
|
@ -254,7 +303,7 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
|
|||
'&:hover': { borderColor: '#845000', bgcolor: '#fff8ee' },
|
||||
}}
|
||||
>
|
||||
Call Waiter to Table 12
|
||||
Call waiter to Table {tableNum}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ import {
|
|||
Chip,
|
||||
Grid,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
|
|
@ -17,44 +25,83 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import BadgeIcon from '@mui/icons-material/Badge';
|
||||
import { useLocale } from '../../i18n/LocaleContext';
|
||||
import { uiLangToLocale } from '../../i18n/messages';
|
||||
|
||||
export type GuestTableOption = {
|
||||
id: number;
|
||||
table_number: string;
|
||||
capacity: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
interface TableLandingViewProps {
|
||||
onStartOrdering: (language: string) => void;
|
||||
onStartOrdering: (language: 'english' | 'hindi' | 'hinglish') => void;
|
||||
onOpenStaffLogin?: () => void;
|
||||
onOpenVoice?: () => void;
|
||||
selectedTable: GuestTableOption | null;
|
||||
tableLocked: boolean;
|
||||
availableTables: GuestTableOption[];
|
||||
tablesLoading?: boolean;
|
||||
tablesError?: string | null;
|
||||
onSelectTable: (table: GuestTableOption) => void;
|
||||
}
|
||||
|
||||
export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||
onStartOrdering,
|
||||
onOpenStaffLogin,
|
||||
onOpenVoice,
|
||||
selectedTable,
|
||||
tableLocked,
|
||||
availableTables,
|
||||
tablesLoading = false,
|
||||
tablesError = null,
|
||||
onSelectTable,
|
||||
}) => {
|
||||
const { t, setLocale } = useLocale();
|
||||
const [selectedLang, setSelectedLang] = useState<'english' | 'hindi' | 'hinglish'>('english');
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const languages = [
|
||||
{
|
||||
id: 'english',
|
||||
emoji: '🇬🇧',
|
||||
name: 'English',
|
||||
subText: 'English',
|
||||
icon: <LanguageIcon fontSize="large" />,
|
||||
},
|
||||
{
|
||||
id: 'hindi',
|
||||
emoji: '🇮🇳',
|
||||
name: 'हिन्दी',
|
||||
subText: 'Hindi',
|
||||
icon: <Typography variant="h5" sx={{ fontWeight: 900, lineHeight: 1 }}>नमस्ते</Typography>,
|
||||
},
|
||||
{
|
||||
id: 'hinglish',
|
||||
emoji: '🤝',
|
||||
name: 'Hinglish',
|
||||
subText: 'Mix',
|
||||
icon: <TranslateIcon fontSize="large" />,
|
||||
},
|
||||
];
|
||||
|
||||
const tableLabel = selectedTable
|
||||
? `${t('tableLabel')} ${selectedTable.table_number}`
|
||||
: t('selectTable');
|
||||
|
||||
const openPicker = () => {
|
||||
if (tableLocked) return;
|
||||
setPickerOpen(true);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!selectedTable && !tableLocked) {
|
||||
setPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
onStartOrdering(selectedLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -67,7 +114,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Hero Image Banner */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 260,
|
||||
|
|
@ -78,7 +124,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Dark overlay gradient */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
|
@ -87,7 +132,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Top bar inside hero */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
|
@ -118,26 +162,23 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
<RestaurantMenuIcon fontSize="small" />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#fff', letterSpacing: '-0.02em' }}>
|
||||
RestroAI
|
||||
{t('brand')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`🪑 Table ${(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tableParam = params.get('table');
|
||||
if (tableParam) {
|
||||
const parsed = parseInt(tableParam);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
clickable={!tableLocked}
|
||||
onClick={openPicker}
|
||||
icon={
|
||||
tableLocked ? (
|
||||
<LockIcon sx={{ color: '#fff !important', fontSize: 16 }} />
|
||||
) : (
|
||||
<ExpandMoreIcon sx={{ color: '#fff !important', fontSize: 18 }} />
|
||||
)
|
||||
}
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/scan\/(\d+)/);
|
||||
if (match) {
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
return 12;
|
||||
})()}`}
|
||||
label={tablesLoading ? '…' : `🪑 ${tableLabel}`}
|
||||
title={tableLocked ? t('tableLockedHint') : t('tablePickHint')}
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
|
|
@ -145,15 +186,21 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
color: '#fff',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
cursor: tableLocked ? 'default' : 'pointer',
|
||||
'& .MuiChip-icon': { ml: 0.5 },
|
||||
}}
|
||||
/>
|
||||
<IconButton size="small" onClick={onOpenStaffLogin} sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
<LockIcon fontSize="small" />
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => (onOpenStaffLogin ? onOpenStaffLogin() : (window.location.href = '/staff.html'))}
|
||||
sx={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
aria-label={t('staffLogin')}
|
||||
>
|
||||
<BadgeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Hero text at bottom of image */}
|
||||
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, px: 2.5, pb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 0.5 }}>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14, color: '#ffb5a0' }} />
|
||||
|
|
@ -170,10 +217,48 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', pb: 4 }}>
|
||||
<Container maxWidth="xs" sx={{ pt: 3 }}>
|
||||
{/* AI Voice Section */}
|
||||
{tablesError && (
|
||||
<Alert severity="warning" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{tablesError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!tableLocked && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
onClick={openPicker}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
borderRadius: '16px',
|
||||
border: '1.5px dashed #e4beb4',
|
||||
bgcolor: '#fff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068', textTransform: 'uppercase' }}>
|
||||
{t('selectTable')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||
{selectedTable ? `Table ${selectedTable.table_number}` : t('tablePickHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ExpandMoreIcon sx={{ color: '#ac2d00' }} />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{tableLocked && selectedTable && (
|
||||
<Alert severity="info" icon={<LockIcon />} sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{t('tableLockedHint')} — Table {selectedTable.table_number}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
|
|
@ -201,14 +286,13 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 12px rgba(172,45,0,0.4)',
|
||||
animation: 'pulse 2.5s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
<MicIcon />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', lineHeight: 1.2 }}>
|
||||
Order with Voice 🎙️
|
||||
{t('voiceOrder')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
|
||||
Tap & say your order in English or Hindi
|
||||
|
|
@ -218,7 +302,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Language Selection */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
|
@ -231,7 +314,7 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Choose Your Language
|
||||
{t('chooseLanguage')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
|
|
@ -241,7 +324,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
<Grid size={{ xs: 4 }} key={lang.id}>
|
||||
<Paper
|
||||
elevation={isSelected ? 3 : 0}
|
||||
onClick={() => setSelectedLang(lang.id as 'english' | 'hindi' | 'hinglish')}
|
||||
onClick={() => {
|
||||
const next = lang.id as 'english' | 'hindi' | 'hinglish';
|
||||
setSelectedLang(next);
|
||||
setLocale(uiLangToLocale(next));
|
||||
}}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '16px',
|
||||
|
|
@ -271,7 +358,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Today's Highlights */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
|
|
@ -300,12 +386,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Main CTA Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => onStartOrdering(selectedLang)}
|
||||
onClick={handleStart}
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
sx={{
|
||||
py: 2,
|
||||
|
|
@ -321,10 +406,9 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
Browse Full Menu
|
||||
{t('startOrdering')}
|
||||
</Button>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 1, color: '#8f7068' }}>
|
||||
<WifiIcon fontSize="small" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
|
|
@ -333,6 +417,39 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
|||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Dialog open={pickerOpen} onClose={() => setPickerOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 900 }}>{t('selectTable')}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{tablesLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)}
|
||||
{!tablesLoading && availableTables.length === 0 && (
|
||||
<Alert severity="warning">{t('noTablesAvailable')}</Alert>
|
||||
)}
|
||||
<List disablePadding>
|
||||
{availableTables.map((table) => (
|
||||
<ListItemButton
|
||||
key={table.id}
|
||||
selected={selectedTable?.id === table.id}
|
||||
onClick={() => {
|
||||
onSelectTable(table);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
sx={{ borderRadius: 2, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`Table ${table.table_number}`}
|
||||
secondary={`Seats ${table.capacity} · ${table.status}`}
|
||||
primaryTypographyProps={{ fontWeight: 800 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
Button,
|
||||
Divider,
|
||||
Fade,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
|
|
@ -18,58 +20,32 @@ import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
|||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import { MENU_ITEMS } from '../../data/menuData';
|
||||
import type { MenuItem } from '../../data/menuData';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
|
||||
import type { VoiceState } from '../../hooks/useSpeechRecognition';
|
||||
import { api } from '../../services/api';
|
||||
import { debugLog, maskToken } from '../../utils/debugLog';
|
||||
|
||||
interface DetectedItem {
|
||||
menuItem: MenuItem;
|
||||
qty: number;
|
||||
}
|
||||
export type AiCartLine = {
|
||||
menu_item_id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit_price: number | string;
|
||||
notes: string[];
|
||||
is_available: boolean;
|
||||
};
|
||||
|
||||
interface VoiceAssistantModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddToCart: (itemId: string, quantity: number) => void;
|
||||
}
|
||||
|
||||
// Detect menu items from voice transcript
|
||||
function parseTranscriptToItems(text: string): DetectedItem[] {
|
||||
const lower = text.toLowerCase();
|
||||
const detectedItems: DetectedItem[] = [];
|
||||
|
||||
for (const item of MENU_ITEMS) {
|
||||
const itemNameLower = item.name.toLowerCase();
|
||||
// Check for item name in transcript
|
||||
if (lower.includes(itemNameLower) || (item.nameHindi && lower.includes(item.nameHindi.toLowerCase()))) {
|
||||
// Try to detect quantity (look for digits near the item name)
|
||||
let qty = 1;
|
||||
const qtxMatch = lower.match(/(\d+)\s*(?:plate|order|piece|nos|number)?(?:of\s+)?(?:\w+\s+)*/);
|
||||
if (qtxMatch) {
|
||||
const num = parseInt(qtxMatch[1], 10);
|
||||
if (num >= 1 && num <= 10) qty = num;
|
||||
}
|
||||
if (/\btwo\b|\bdo\b/i.test(lower)) qty = 2;
|
||||
if (/\bthree\b|\bteen\b/i.test(lower)) qty = 3;
|
||||
if (/\bfour\b|\bchar\b/i.test(lower)) qty = 4;
|
||||
|
||||
// avoid duplicates
|
||||
if (!detectedItems.find((d) => d.menuItem.id === item.id)) {
|
||||
detectedItems.push({ menuItem: item, qty });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detectedItems;
|
||||
onSyncCart?: (cart: AiCartLine[]) => void;
|
||||
}
|
||||
|
||||
const QUICK_COMMANDS = [
|
||||
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken and 2 garlic naan please' },
|
||||
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani for table 12' },
|
||||
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi" },
|
||||
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken kam spicy and 2 garlic naan please' },
|
||||
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani please' },
|
||||
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi please" },
|
||||
{ label: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
|
||||
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
|
||||
];
|
||||
|
|
@ -78,11 +54,118 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
open,
|
||||
onClose,
|
||||
onAddToCart,
|
||||
onSyncCart,
|
||||
}) => {
|
||||
const [internalVoiceState, setInternalVoiceState] = useState<VoiceState>('idle');
|
||||
const [detectedItems, setDetectedItems] = useState<DetectedItem[]>([]);
|
||||
const [cartLines, setCartLines] = useState<AiCartLine[]>([]);
|
||||
const [assistantText, setAssistantText] = useState('');
|
||||
const [toolCalls, setToolCalls] = useState<string[]>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [typedOrder, setTypedOrder] = useState('');
|
||||
|
||||
const applyCart = useCallback(
|
||||
(cart: AiCartLine[]) => {
|
||||
setCartLines(cart);
|
||||
if (onSyncCart) {
|
||||
onSyncCart(cart);
|
||||
return;
|
||||
}
|
||||
for (const line of cart) {
|
||||
onAddToCart(String(line.menu_item_id), line.quantity);
|
||||
}
|
||||
},
|
||||
[onAddToCart, onSyncCart]
|
||||
);
|
||||
|
||||
const runAiTurn = useCallback(
|
||||
async (text: string, submit = false) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
debugLog.warn('voice', 'runAiTurn skipped — empty text');
|
||||
setApiError('Say or type an order first.');
|
||||
return;
|
||||
}
|
||||
if (busyRef.current) {
|
||||
debugLog.warn('voice', 'runAiTurn skipped — already busy', { trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('customer_session_token');
|
||||
if (!sessionToken) {
|
||||
debugLog.error('voice', 'runAiTurn blocked — no session token');
|
||||
setApiError('No table session — close this, pick a table, then Start ordering.');
|
||||
return;
|
||||
}
|
||||
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setApiError(null);
|
||||
setAssistantText('');
|
||||
setToolCalls([]);
|
||||
setConfirmed(false);
|
||||
setLiveTranscript(trimmed);
|
||||
|
||||
const looksHindi = /[\u0900-\u097F]/.test(trimmed);
|
||||
const language = looksHindi || selectedLang.startsWith('hi') ? 'hi' : 'en';
|
||||
const started = performance.now();
|
||||
debugLog.info('voice', 'runAiTurn start', {
|
||||
submit,
|
||||
language,
|
||||
selectedLang,
|
||||
transcript: trimmed.slice(0, 160),
|
||||
sessionToken: maskToken(sessionToken),
|
||||
tableId: sessionStorage.getItem('customer_table_id'),
|
||||
tableNumber: sessionStorage.getItem('customer_table_number'),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.aiChatTurn({ transcript: trimmed, language, submit });
|
||||
|
||||
debugLog.info('voice', 'runAiTurn success', {
|
||||
ms: Math.round(performance.now() - started),
|
||||
tools: result.tool_calls,
|
||||
cart: (result.cart || []).map((c) => `${c.quantity}×${c.name}`),
|
||||
orderId: result.order?.order_id,
|
||||
assistant: (result.assistant_text || '').slice(0, 120),
|
||||
});
|
||||
|
||||
setLiveTranscript(result.transcript || trimmed);
|
||||
setAssistantText(result.assistant_text || '');
|
||||
setToolCalls(result.tool_calls || []);
|
||||
if (result.order) {
|
||||
setConfirmed(true);
|
||||
applyCart([]);
|
||||
} else {
|
||||
applyCart(result.cart || []);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = err instanceof Error ? err.message : 'AI ordering failed';
|
||||
const message =
|
||||
raw.length > 160 || raw.includes('failed_generation') || raw.includes('tool_use_failed')
|
||||
? 'AI ordering failed — please try again or use the menu.'
|
||||
: raw;
|
||||
debugLog.error('voice', 'runAiTurn failed', {
|
||||
ms: Math.round(performance.now() - started),
|
||||
raw: String(raw).slice(0, 300),
|
||||
shown: message,
|
||||
});
|
||||
setApiError(message);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[applyCart, selectedLang]
|
||||
);
|
||||
|
||||
const runAiTurnRef = useRef(runAiTurn);
|
||||
useEffect(() => {
|
||||
runAiTurnRef.current = runAiTurn;
|
||||
}, [runAiTurn]);
|
||||
|
||||
const {
|
||||
voiceState: recognitionState,
|
||||
|
|
@ -93,101 +176,106 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
stopListening,
|
||||
resetTranscript,
|
||||
error,
|
||||
} = useSpeechRecognition();
|
||||
} = useSpeechRecognition((finalText) => {
|
||||
debugLog.info('stt', 'final transcript → AI', { text: finalText.slice(0, 160) });
|
||||
void runAiTurnRef.current(finalText, false);
|
||||
});
|
||||
|
||||
const { speak, cancel } = useSpeechSynthesis((s) => setInternalVoiceState(s));
|
||||
|
||||
const voiceState = internalVoiceState !== 'idle' ? internalVoiceState : recognitionState;
|
||||
|
||||
// Parse items when transcript updates
|
||||
useEffect(() => {
|
||||
if (transcript.trim().length > 0 && recognitionState === 'thinking') {
|
||||
const items = parseTranscriptToItems(transcript);
|
||||
setDetectedItems(items);
|
||||
setInternalVoiceState('thinking');
|
||||
|
||||
// AI confirmation response via TTS
|
||||
if (items.length > 0) {
|
||||
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
|
||||
setTimeout(() => {
|
||||
speak(
|
||||
`Got it! I found ${items.length} item${items.length > 1 ? 's' : ''}: ${itemList}. Please confirm to add them to your cart.`,
|
||||
if (!open) return;
|
||||
debugLog.info('voice', 'modal opened', {
|
||||
isSupported,
|
||||
selectedLang,
|
||||
() => setInternalVoiceState('idle')
|
||||
);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
speak(
|
||||
"Sorry, I couldn't identify any items from your order. Please try again or use the quick commands below.",
|
||||
selectedLang,
|
||||
() => setInternalVoiceState('idle')
|
||||
);
|
||||
setInternalVoiceState('idle');
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
}, [transcript, recognitionState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancel();
|
||||
resetTranscript();
|
||||
setDetectedItems([]);
|
||||
hasSession: Boolean(sessionStorage.getItem('customer_session_token')),
|
||||
tableId: sessionStorage.getItem('customer_table_id'),
|
||||
tableNumber: sessionStorage.getItem('customer_table_number'),
|
||||
debug: debugLog.enabled(),
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setApiError(null);
|
||||
setAssistantText('');
|
||||
setToolCalls([]);
|
||||
setConfirmed(false);
|
||||
setInternalVoiceState('idle');
|
||||
setLiveTranscript('');
|
||||
setTypedOrder('');
|
||||
setCartLines([]);
|
||||
resetTranscript();
|
||||
}, [open, resetTranscript, isSupported, selectedLang]);
|
||||
|
||||
const voiceState: VoiceState = busy
|
||||
? 'thinking'
|
||||
: recognitionState === 'listening'
|
||||
? 'listening'
|
||||
: 'idle';
|
||||
|
||||
const handleClose = () => {
|
||||
stopListening();
|
||||
resetTranscript();
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
onClose();
|
||||
}, [cancel, resetTranscript, onClose]);
|
||||
};
|
||||
|
||||
const handleMicToggle = () => {
|
||||
if (voiceState === 'listening') {
|
||||
if (busyRef.current) {
|
||||
debugLog.warn('stt', 'mic toggle ignored — AI busy');
|
||||
return;
|
||||
}
|
||||
if (!isSupported) {
|
||||
debugLog.warn('stt', 'mic unsupported');
|
||||
setApiError('Speech recognition is unavailable here. Type your order or tap a quick command.');
|
||||
return;
|
||||
}
|
||||
if (recognitionState === 'listening') {
|
||||
debugLog.info('stt', 'mic stop requested');
|
||||
stopListening();
|
||||
} else {
|
||||
setDetectedItems([]);
|
||||
setConfirmed(false);
|
||||
return;
|
||||
}
|
||||
debugLog.info('stt', 'mic start', { lang: selectedLang });
|
||||
setApiError(null);
|
||||
resetTranscript();
|
||||
startListening(selectedLang);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickCommand = (text: string) => {
|
||||
const items = parseTranscriptToItems(text);
|
||||
setDetectedItems(items);
|
||||
setInternalVoiceState('thinking');
|
||||
if (items.length > 0) {
|
||||
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
|
||||
speak(
|
||||
`Great choice! Found ${itemList}. Tap confirm to add to your cart.`,
|
||||
selectedLang,
|
||||
() => setInternalVoiceState('idle')
|
||||
);
|
||||
}
|
||||
debugLog.info('voice', 'quick command', { text: text.slice(0, 120) });
|
||||
void runAiTurn(text, false);
|
||||
};
|
||||
|
||||
const handleTypedSubmit = () => {
|
||||
debugLog.info('voice', 'typed submit', { text: typedOrder.slice(0, 120) });
|
||||
void runAiTurn(typedOrder, false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
detectedItems.forEach((d) => onAddToCart(d.menuItem.id, d.qty));
|
||||
setConfirmed(true);
|
||||
speak('Items added to your cart! Enjoy your meal.', selectedLang);
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 2000);
|
||||
const text =
|
||||
liveTranscript.trim() ||
|
||||
'Please place my current cart as an order. I confirm submit.';
|
||||
void runAiTurn(text, true);
|
||||
};
|
||||
|
||||
const orbColor = {
|
||||
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' },
|
||||
listening: { bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)', shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)' },
|
||||
listening: {
|
||||
bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)',
|
||||
shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)',
|
||||
},
|
||||
thinking: { bg: 'linear-gradient(135deg, #845000, #b07000)', shadow: '0 8px 24px rgba(132,80,0,0.5)' },
|
||||
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 0 0 16px rgba(0,106,46,0.2), 0 0 0 32px rgba(0,106,46,0.1)' },
|
||||
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 8px 24px rgba(0,106,46,0.4)' },
|
||||
error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
|
||||
};
|
||||
|
||||
const orbLabel = {
|
||||
idle: 'Tap to Speak',
|
||||
listening: '🎙️ Listening...',
|
||||
thinking: '🤔 Processing...',
|
||||
speaking: '🔊 AI Speaking...',
|
||||
error: '⚠️ Try Again',
|
||||
idle: isSupported ? 'Tap to Speak' : 'Type or use quick commands',
|
||||
listening: 'Listening… tap again to stop',
|
||||
thinking: 'AI thinking…',
|
||||
speaking: 'Done',
|
||||
error: 'Try Again',
|
||||
};
|
||||
|
||||
const shownTranscript = liveTranscript || transcript || interimTranscript;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
|
|
@ -205,12 +293,10 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}>
|
||||
{/* Handle Bar */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
|
||||
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
|
||||
|
|
@ -219,7 +305,7 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
AI Voice Assistant
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}>
|
||||
Speak your order naturally in English or Hindi
|
||||
Type, tap a quick command, or speak → Groq fills your cart
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -228,51 +314,52 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
|
||||
{!isSupported && (
|
||||
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}>
|
||||
Voice recognition requires Google Chrome browser. You can still use quick commands below.
|
||||
Speech recognition API is missing in this browser. Type your order or use a quick command.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{isSupported && (
|
||||
<Alert severity="info" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Mic tip (Chrome): tap the orange button, speak, then tap again (or Stop) to send the order to AI.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(error || apiError) && (
|
||||
<Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
|
||||
{error}
|
||||
{apiError || error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Voice Orb */}
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Box
|
||||
onClick={handleMicToggle}
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: '50%',
|
||||
background: orbColor[voiceState].bg,
|
||||
boxShadow: orbColor[voiceState].shadow,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
transform: voiceState === 'listening' || voiceState === 'speaking' ? 'scale(1.12)' : 'scale(1)',
|
||||
mb: 2,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
mb: 1.5,
|
||||
pointerEvents: busy ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{voiceState === 'listening' ? (
|
||||
<GraphicEqIcon sx={{ color: '#fff', fontSize: 52 }} />
|
||||
) : voiceState === 'speaking' ? (
|
||||
<VolumeUpIcon sx={{ color: '#fff', fontSize: 52 }} />
|
||||
) : voiceState === 'thinking' ? (
|
||||
<SmartToyIcon sx={{ color: '#fff', fontSize: 52 }} />
|
||||
{busy ? (
|
||||
<CircularProgress size={40} sx={{ color: '#fff' }} />
|
||||
) : voiceState === 'listening' ? (
|
||||
<GraphicEqIcon sx={{ color: '#fff', fontSize: 48 }} />
|
||||
) : (
|
||||
<MicIcon sx={{ color: '#fff', fontSize: 52 }} />
|
||||
<MicIcon sx={{ color: '#fff', fontSize: 48 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
|
||||
{orbLabel[voiceState]}
|
||||
</Typography>
|
||||
|
||||
|
|
@ -281,22 +368,21 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={stopListening}
|
||||
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||
onClick={handleMicToggle}
|
||||
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00', mb: 1 }}
|
||||
>
|
||||
Stop & Process
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Language Toggle */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1.5 }}>
|
||||
{['en-IN', 'hi-IN'].map((lang) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1 }}>
|
||||
{(['en-IN', 'hi-IN'] as const).map((lang) => (
|
||||
<Chip
|
||||
key={lang}
|
||||
label={lang === 'en-IN' ? '🇮🇳 English' : '🇮🇳 हिन्दी'}
|
||||
label={lang === 'en-IN' ? 'English' : 'हिन्दी'}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => setSelectedLang(lang as 'en-IN' | 'hi-IN')}
|
||||
onClick={() => setSelectedLang(lang)}
|
||||
color={selectedLang === lang ? 'primary' : 'default'}
|
||||
variant={selectedLang === lang ? 'filled' : 'outlined'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
|
|
@ -305,8 +391,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Live Transcript */}
|
||||
{(transcript || interimTranscript) && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder='Type order e.g. "2 butter chicken and garlic naan"'
|
||||
value={typedOrder}
|
||||
disabled={busy}
|
||||
onChange={(e) => setTypedOrder(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleTypedSubmit();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={busy || !typedOrder.trim()}
|
||||
onClick={handleTypedSubmit}
|
||||
sx={{ fontWeight: 800, bgcolor: '#ac2d00', px: 2, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{shownTranscript && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
|
|
@ -318,18 +425,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
|
||||
WHAT I HEARD:
|
||||
ORDER TEXT:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
|
||||
"{transcript}{interimTranscript && <span style={{ color: '#9e9e9e' }}>{interimTranscript}</span>}"
|
||||
"{shownTranscript}"
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Detected Items */}
|
||||
<Fade in={detectedItems.length > 0}>
|
||||
{assistantText && (
|
||||
<Alert severity="success" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{assistantText}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{toolCalls.length > 0 && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, color: '#8f7068', fontWeight: 700 }}>
|
||||
Tools: {toolCalls.join(' → ')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Fade in={cartLines.length > 0}>
|
||||
<Box>
|
||||
{detectedItems.length > 0 && (
|
||||
{cartLines.length > 0 && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
|
|
@ -341,11 +459,11 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
|
||||
✅ Items Detected:
|
||||
Cart from AI:
|
||||
</Typography>
|
||||
{detectedItems.map((d) => (
|
||||
{cartLines.map((line) => (
|
||||
<Box
|
||||
key={d.menuItem.id}
|
||||
key={`${line.menu_item_id}-${line.quantity}-${line.notes.join(',')}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -356,15 +474,17 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{d.qty}× {d.menuItem.name}
|
||||
{line.quantity}× {line.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#546067' }}>
|
||||
₹{(d.qty * d.menuItem.price).toFixed(0)}
|
||||
₹{(Number(line.unit_price) * line.quantity).toFixed(0)}
|
||||
{line.notes?.length ? ` · ${line.notes.join(', ')}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={d.menuItem.dietary === 'non-veg' ? '🔴 Non-Veg' : '🟢 Veg'}
|
||||
label={line.is_available ? 'Available' : "86'd"}
|
||||
size="small"
|
||||
color={line.is_available ? 'success' : 'warning'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
</Box>
|
||||
|
|
@ -375,24 +495,27 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => { setDetectedItems([]); resetTranscript(); }}
|
||||
onClick={() => {
|
||||
setCartLines([]);
|
||||
setAssistantText('');
|
||||
setLiveTranscript('');
|
||||
setToolCalls([]);
|
||||
onSyncCart?.([]);
|
||||
}}
|
||||
sx={{ fontWeight: 700 }}
|
||||
>
|
||||
Retry
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={confirmed ? <CheckCircleIcon /> : <AddShoppingCartIcon />}
|
||||
startIcon={confirmed ? <CheckCircleIcon /> : <SendIcon />}
|
||||
onClick={handleConfirm}
|
||||
disabled={confirmed}
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
bgcolor: confirmed ? '#006a2e' : '#ac2d00',
|
||||
}}
|
||||
disabled={confirmed || busy}
|
||||
sx={{ fontWeight: 800, bgcolor: confirmed ? '#006a2e' : '#ac2d00' }}
|
||||
>
|
||||
{confirmed ? 'Added!' : 'Add to Cart'}
|
||||
{confirmed ? 'Ordered!' : 'Confirm & Place Order'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
|
@ -400,7 +523,6 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
</Box>
|
||||
</Fade>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
||||
QUICK COMMANDS
|
||||
|
|
@ -408,9 +530,9 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
</Divider>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{QUICK_COMMANDS.map((cmd, idx) => (
|
||||
{QUICK_COMMANDS.map((cmd) => (
|
||||
<Paper
|
||||
key={idx}
|
||||
key={cmd.label}
|
||||
elevation={0}
|
||||
onClick={() => handleQuickCommand(cmd.text)}
|
||||
sx={{
|
||||
|
|
@ -418,18 +540,20 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
|||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e4beb4',
|
||||
cursor: 'pointer',
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
opacity: busy ? 0.6 : 1,
|
||||
pointerEvents: busy ? 'none' : 'auto',
|
||||
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
"{cmd.label}"
|
||||
</Typography>
|
||||
<AddShoppingCartIcon sx={{ ml: 'auto', color: '#ac2d00', fontSize: 16 }} />
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { KDSTicketCard } from './KDSTicketCard';
|
|||
|
||||
interface KDSKanbanProps {
|
||||
orders: KDSOrder[];
|
||||
busyOrderId?: string | null;
|
||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||
onToggleItem: (orderId: string, itemId: string) => void;
|
||||
onAddSampleOrder: () => void;
|
||||
|
|
@ -25,6 +26,7 @@ interface KDSKanbanProps {
|
|||
|
||||
export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||
orders,
|
||||
busyOrderId = null,
|
||||
onStatusChange,
|
||||
onToggleItem,
|
||||
onAddSampleOrder,
|
||||
|
|
@ -50,26 +52,38 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
|||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
|
||||
<Box sx={{ width: '100%', maxWidth: '100%' }}>
|
||||
{/* Top Filter Bar */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
p: { xs: 1.5, md: 2 },
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
gap: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
border: '1px solid #e2e2e2',
|
||||
borderRadius: '12px',
|
||||
bgcolor: '#fff',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<RestaurantMenuIcon />
|
||||
KDS Kanban Board
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: '#ac2d00',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontSize: '1.05rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<RestaurantMenuIcon fontSize="small" />
|
||||
KDS Board
|
||||
</Typography>
|
||||
|
||||
<ButtonGroup variant="outlined" size="small">
|
||||
|
|
@ -124,19 +138,22 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
|||
</Paper>
|
||||
|
||||
{/* 4 Column Kanban Board Grid */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={1.5} alignItems="stretch">
|
||||
{columns.map((col) => {
|
||||
const colOrders = filteredOrders.filter((o) => o.status === col.status);
|
||||
return (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={col.status}>
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 3 }} key={col.status}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
minHeight: '75vh',
|
||||
p: 1.25,
|
||||
minHeight: { xs: 320, lg: 'calc(100vh - 200px)' },
|
||||
height: '100%',
|
||||
backgroundColor: '#f6f7f8',
|
||||
border: '1px solid #e4beb4',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Column Header */}
|
||||
|
|
@ -145,12 +162,23 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
gap: 1,
|
||||
mb: 1.5,
|
||||
pb: 1,
|
||||
borderBottom: '2px solid #e2e2e2',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: col.color,
|
||||
letterSpacing: '0.04em',
|
||||
fontSize: '0.7rem',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: col.color, letterSpacing: '0.05em' }}>
|
||||
{col.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
|
|
@ -165,16 +193,16 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
|||
</Box>
|
||||
|
||||
{/* Column Tickets */}
|
||||
<Box>
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', minHeight: 0, pr: 0.25 }}>
|
||||
{colOrders.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
color: '#8f7068',
|
||||
border: '2px dashed #e2e2e2',
|
||||
borderRadius: '8px',
|
||||
mt: 2,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
|
|
@ -186,6 +214,7 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
|||
<KDSTicketCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
busy={busyOrderId === order.id}
|
||||
onStatusChange={onStatusChange}
|
||||
onToggleItem={onToggleItem}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
|
|
@ -22,16 +23,17 @@ import { StatusBadge } from '../common/StatusBadge';
|
|||
|
||||
interface KDSTicketCardProps {
|
||||
order: KDSOrder;
|
||||
busy?: boolean;
|
||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||
onToggleItem: (orderId: string, itemId: string) => void;
|
||||
}
|
||||
|
||||
export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||
order,
|
||||
busy = false,
|
||||
onStatusChange,
|
||||
onToggleItem,
|
||||
}) => {
|
||||
// Calculate SLA timer color
|
||||
const isUrgent = order.timeElapsedMinutes >= 25 || order.priority === 'urgent';
|
||||
const isWarning = order.timeElapsedMinutes >= 15 && order.timeElapsedMinutes < 25;
|
||||
|
||||
|
|
@ -62,34 +64,56 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
|||
served: 'COMPLETED',
|
||||
}[order.status];
|
||||
|
||||
const allItemsChecked = order.items.every((i) => i.completed);
|
||||
const allItemsChecked = order.items.length > 0 && order.items.every((i) => i.completed);
|
||||
|
||||
const locationLabel = order.tableNumber
|
||||
? `Table ${order.tableNumber}`
|
||||
: order.customerName ||
|
||||
(order.orderType === 'Takeaway'
|
||||
? 'Takeaway'
|
||||
: order.orderType === 'Delivery'
|
||||
? 'Delivery'
|
||||
: 'Walk-in');
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
mb: 1.5,
|
||||
position: 'relative',
|
||||
borderLeft: isUrgent ? '5px solid #ba1a1a' : isWarning ? '5px solid #845000' : '5px solid #ac2d00',
|
||||
borderLeft: isUrgent
|
||||
? '5px solid #ba1a1a'
|
||||
: isWarning
|
||||
? '5px solid #845000'
|
||||
: '5px solid #ac2d00',
|
||||
transition: 'transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
|
||||
overflow: 'hidden',
|
||||
opacity: busy ? 0.75 : 1,
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Ticket Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
p: 1.25,
|
||||
backgroundColor: '#f3f3f3',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
borderBottom: '1px solid #e2e2e2',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontFamily: '"JetBrains Mono", monospace', fontWeight: 800 }}>
|
||||
<Box sx={{ minWidth: 0, flex: '1 1 auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontWeight: 800,
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
#{order.ticketNumber}
|
||||
</Typography>
|
||||
<StatusBadge type={order.orderType} />
|
||||
|
|
@ -103,79 +127,102 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
|||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#5b4139', display: 'block', mt: 0.2 }}>
|
||||
{order.tableNumber ? `Table ${order.tableNumber}` : order.customerName || 'Walk-in Guest'}
|
||||
{order.serverName && ` • Server: ${order.serverName}`}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#5b4139',
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1.35,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{locationLabel}
|
||||
{order.serverName ? ` · ${order.serverName}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* SLA Timer Badge */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
gap: 0.4,
|
||||
px: 0.85,
|
||||
py: 0.35,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: timerBg,
|
||||
color: timerColor,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AccessTimeIcon fontSize="small" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.8rem' }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.75rem' }}>
|
||||
{order.timeElapsedMinutes}m
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Ticket Body / Items */}
|
||||
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{order.items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
onClick={() => onToggleItem(order.id, item.id)}
|
||||
onClick={() => !busy && onToggleItem(order.id, item.id)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
p: 0.8,
|
||||
gap: 1,
|
||||
p: 0.75,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
backgroundColor: item.completed ? '#f0eded' : 'transparent',
|
||||
textDecoration: item.completed ? 'line-through' : 'none',
|
||||
opacity: item.completed ? 0.6 : 1,
|
||||
'&:hover': {
|
||||
backgroundColor: '#f6f3f2',
|
||||
},
|
||||
opacity: item.completed ? 0.65 : 1,
|
||||
'&:hover': busy ? undefined : { backgroundColor: '#f6f3f2' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, minWidth: 0, flex: 1 }}>
|
||||
<Checkbox
|
||||
checked={!!item.completed}
|
||||
size="small"
|
||||
sx={{ p: 0, mt: 0.2, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
|
||||
disabled={busy}
|
||||
sx={{ p: 0, mt: 0.15, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
|
||||
/>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
color: '#ac2d00',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{item.quantity}x
|
||||
{item.quantity}×
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
textDecoration: item.completed ? 'line-through' : 'none',
|
||||
lineHeight: 1.3,
|
||||
wordBreak: 'break-word',
|
||||
flex: '1 1 80px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.dietary && <StatusBadge dietary={item.dietary} />}
|
||||
{item.dietary && (
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<StatusBadge dietary={item.dietary} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{item.notes && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
|
@ -183,21 +230,24 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
|||
color: '#ba1a1a',
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
mt: 0.2,
|
||||
mt: 0.35,
|
||||
backgroundColor: '#ffdad6',
|
||||
px: 0.8,
|
||||
py: 0.2,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
⚠️ {item.notes}
|
||||
{item.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#5b4139', fontWeight: 700, flexShrink: 0, pt: 0.2 }}
|
||||
>
|
||||
₹{(item.price * item.quantity).toFixed(0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -206,14 +256,22 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
|||
|
||||
<Divider />
|
||||
|
||||
{/* Ticket Footer Actions */}
|
||||
<CardActions sx={{ p: 1, justifyContent: 'space-between', backgroundColor: '#fafafa' }}>
|
||||
<Box>
|
||||
{prevStatus && (
|
||||
<Tooltip title={`Revert to ${prevStatus}`}>
|
||||
<IconButton size="small" onClick={() => onStatusChange(order.id, prevStatus)}>
|
||||
<Tooltip title={`Move back to ${prevStatus}`}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStatusChange(order.id, prevStatus);
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -222,12 +280,18 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
|||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={busy}
|
||||
color={allItemsChecked ? 'success' : order.status === 'pending' ? 'primary' : 'warning'}
|
||||
onClick={() => onStatusChange(order.id, nextStatus)}
|
||||
endIcon={<ArrowForwardIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStatusChange(order.id, nextStatus);
|
||||
}}
|
||||
endIcon={
|
||||
busy ? <CircularProgress size={14} color="inherit" /> : <ArrowForwardIcon fontSize="small" />
|
||||
}
|
||||
sx={{ fontWeight: 800, fontSize: '0.75rem' }}
|
||||
>
|
||||
{actionButtonText}
|
||||
{busy ? 'Updating…' : actionButtonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Chip
|
||||
|
|
|
|||
|
|
@ -1,210 +1,359 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Grid,
|
||||
Paper,
|
||||
Divider,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import type { VoiceCommandSuggestion } from '../../types';
|
||||
import InventoryIcon from '@mui/icons-material/Inventory';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
type LowStockRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
current_stock: number;
|
||||
reorder_threshold: number;
|
||||
is_low: boolean;
|
||||
};
|
||||
|
||||
interface VoiceOrderingViewProps {
|
||||
onAddOrderFromVoice: (transcript: string) => void;
|
||||
onAddOrderFromVoice?: (transcript: string) => void;
|
||||
}
|
||||
|
||||
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = ({ onAddOrderFromVoice }) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [transcript, setTranscript] = useState(
|
||||
'Do Butter Chicken, teen Garlic Naan aur ek Mango Lassi Table 12 ke liye'
|
||||
const QUICK_PROMPTS = [
|
||||
{ label: 'What is low stock?', text: 'What ingredients are low on stock right now?' },
|
||||
{ label: 'Add 2kg tomato', text: 'please add 2 kgs tomato in inventory' },
|
||||
{ label: 'Chicken stock?', text: 'How much chicken do we have left?' },
|
||||
{ label: 'Restock cream 1L', text: 'add 1 litre cream to inventory' },
|
||||
{ label: 'Send alert now', text: 'Send a low stock alert notification now' },
|
||||
{ label: 'Full inventory', text: 'Give me a quick inventory stock overview' },
|
||||
];
|
||||
|
||||
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = () => {
|
||||
const [typed, setTyped] = useState('');
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [assistantText, setAssistantText] = useState('');
|
||||
const [toolCalls, setToolCalls] = useState<string[]>([]);
|
||||
const [lowStock, setLowStock] = useState<LowStockRow[]>([]);
|
||||
const [updates, setUpdates] = useState<LowStockRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const runStaffAi = useCallback(async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLiveTranscript(trimmed);
|
||||
setAssistantText('');
|
||||
try {
|
||||
const language = selectedLang.startsWith('hi') || /[\u0900-\u097F]/.test(trimmed) ? 'hi' : 'en';
|
||||
const result = await api.staffAiChatTurn({ transcript: trimmed, language });
|
||||
setAssistantText(result.assistant_text || '');
|
||||
setToolCalls(result.tool_calls || []);
|
||||
setLowStock(result.low_stock || []);
|
||||
setUpdates(result.updates || []);
|
||||
setLiveTranscript(result.transcript || trimmed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Staff AI failed');
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [selectedLang]);
|
||||
|
||||
const runStaffAiRef = useRef(runStaffAi);
|
||||
useEffect(() => {
|
||||
runStaffAiRef.current = runStaffAi;
|
||||
}, [runStaffAi]);
|
||||
|
||||
const {
|
||||
voiceState,
|
||||
transcript,
|
||||
interimTranscript,
|
||||
isSupported,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetTranscript,
|
||||
error: sttError,
|
||||
} = useSpeechRecognition((finalText) => {
|
||||
void runStaffAiRef.current(finalText);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Prefetch low stock so the panel isn't empty on first open.
|
||||
void api
|
||||
.getLowStockInventory()
|
||||
.then((rows) => {
|
||||
const list = Array.isArray(rows) ? rows : rows?.items || [];
|
||||
setLowStock(
|
||||
list.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
name: String(r.name),
|
||||
unit: String(r.unit || ''),
|
||||
current_stock: Number(r.current_stock ?? r.stock ?? 0),
|
||||
reorder_threshold: Number(r.reorder_threshold ?? r.minStock ?? 0),
|
||||
is_low: true,
|
||||
})),
|
||||
);
|
||||
const [detectedItems] = useState([
|
||||
{ name: 'Butter Chicken', qty: 2, price: 360, dietary: 'non-veg' as const },
|
||||
{ name: 'Garlic Naan', qty: 3, price: 80, dietary: 'veg' as const },
|
||||
{ name: 'Mango Lassi', qty: 1, price: 120, dietary: 'veg' as const },
|
||||
]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore prefetch errors */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const samplePrompts: VoiceCommandSuggestion[] = [
|
||||
{
|
||||
id: '1',
|
||||
label: '2 Butter Chicken & Garlic Naan – Table 12',
|
||||
prompt: 'Do Butter Chicken aur do Garlic Naan Table 12 ke liye',
|
||||
category: 'order',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Hyderabadi Biryani + Raita – Table 8',
|
||||
prompt: 'Ek Hyderabadi Chicken Biryani with extra raita for Table 8',
|
||||
category: 'order',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'Jain Palak Paneer – no onion no garlic',
|
||||
prompt: 'Add 1 Palak Paneer Jain preparation no onion no garlic for Table 4',
|
||||
category: 'order',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
label: 'Extra Cutlery + Tissue for Table 3',
|
||||
prompt: 'Please send extra cutlery and tissue paper to Table 3',
|
||||
category: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
const handleConfirmOrder = () => {
|
||||
onAddOrderFromVoice(transcript);
|
||||
setSubmitted(true);
|
||||
setTimeout(() => setSubmitted(false), 3000);
|
||||
const toggleMic = () => {
|
||||
if (voiceState === 'listening') {
|
||||
stopListening();
|
||||
const spoken = (transcript || interimTranscript || '').trim();
|
||||
if (spoken) void runStaffAi(spoken);
|
||||
return;
|
||||
}
|
||||
resetTranscript();
|
||||
setLiveTranscript('');
|
||||
startListening(selectedLang);
|
||||
};
|
||||
|
||||
const totalValue = detectedItems.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||
const shownTranscript = liveTranscript || transcript || interimTranscript;
|
||||
const listening = voiceState === 'listening';
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, maxWidth: 1100, mx: 'auto' }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
|
||||
Staff Voice Assistant Terminal
|
||||
Staff AI Assistant
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#5b4139' }}>
|
||||
Hands-free voice intake for servers & kitchen staff. Supports Hindi, English & Hinglish.
|
||||
Ask about inventory by voice or text — get low-stock checks and send alerts anytime.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Voice Orb */}
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid size={{ xs: 12, md: 5 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
textAlign: 'center',
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #ffdbd1 100%)',
|
||||
p: 3,
|
||||
borderRadius: '20px',
|
||||
border: '1px solid #e4beb4',
|
||||
bgcolor: '#fff9f7',
|
||||
textAlign: 'center',
|
||||
minHeight: 360,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setIsListening(!isListening)}
|
||||
onClick={toggleMic}
|
||||
sx={{
|
||||
width: 130,
|
||||
height: 130,
|
||||
width: 110,
|
||||
height: 110,
|
||||
borderRadius: '50%',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
cursor: 'pointer',
|
||||
background: isListening
|
||||
? 'radial-gradient(circle, #d53e0b 0%, #ac2d00 70%)'
|
||||
: 'radial-gradient(circle, #ac2d00 0%, #872100 100%)',
|
||||
boxShadow: isListening
|
||||
? '0 0 0 18px rgba(213, 62, 11, 0.2), 0 0 0 36px rgba(172, 45, 0, 0.1)'
|
||||
: '0 4px 20px rgba(172, 45, 0, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
transform: isListening ? 'scale(1.08)' : 'scale(1)',
|
||||
color: '#fff',
|
||||
background: listening
|
||||
? 'linear-gradient(135deg, #d53e0b, #e75a2b)'
|
||||
: busy
|
||||
? 'linear-gradient(135deg, #845000, #b07000)'
|
||||
: 'linear-gradient(135deg, #ac2d00, #d53e0b)',
|
||||
boxShadow: listening
|
||||
? '0 0 0 14px rgba(213,62,11,0.18)'
|
||||
: '0 8px 24px rgba(172,45,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{isListening ? <GraphicEqIcon sx={{ fontSize: 60 }} /> : <MicIcon sx={{ fontSize: 60 }} />}
|
||||
{busy ? <CircularProgress size={36} color="inherit" /> : listening ? <StopIcon sx={{ fontSize: 40 }} /> : <MicIcon sx={{ fontSize: 40 }} />}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
|
||||
{isListening ? '🎙️ Listening & Parsing Voice Input...' : 'Tap Orb or Speak to Begin Order'}
|
||||
<Typography sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||
{busy ? 'AI checking inventory…' : listening ? 'Listening… tap to stop' : isSupported ? 'Tap to speak' : 'Type a question below'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600, display: 'block', mb: 3 }}>
|
||||
Multilingual: English · Hindi · Hinglish
|
||||
</Typography>
|
||||
|
||||
{/* Live Transcript */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, maxWidth: 700, mx: 'auto', bgcolor: '#ffffff', borderRadius: '12px', border: '2px solid #ac2d00', textAlign: 'left' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
|
||||
LIVE TRANSCRIPT:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
|
||||
"{transcript}"
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
{/* Quick Prompts */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#546067', mb: 1.5, textTransform: 'uppercase' }}>
|
||||
Suggested Quick Voice Commands
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
{samplePrompts.map((s) => (
|
||||
<Grid size={{ xs: 12, sm: 6 }} key={s.id}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{(['en-IN', 'hi-IN'] as const).map((lang) => (
|
||||
<Chip
|
||||
icon={<SmartToyIcon fontSize="small" />}
|
||||
label={s.label}
|
||||
onClick={() => { setTranscript(s.prompt); setIsListening(true); setTimeout(() => setIsListening(false), 1500); }}
|
||||
clickable
|
||||
sx={{ width: '100%', justifyContent: 'flex-start', py: 2.5, px: 1, fontWeight: 700, bgcolor: '#ffffff', border: '1px solid #e4beb4', '&:hover': { bgcolor: '#ffdbd1' } }}
|
||||
key={lang}
|
||||
label={lang === 'en-IN' ? 'English' : 'Hindi'}
|
||||
size="small"
|
||||
onClick={() => setSelectedLang(lang)}
|
||||
color={selectedLang === lang ? 'primary' : 'default'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Detected Items */}
|
||||
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>AI Extracted Items</Typography>
|
||||
<Chip label="Table 12" color="primary" sx={{ fontWeight: 800 }} />
|
||||
</Box>
|
||||
|
||||
<List disablePadding>
|
||||
{detectedItems.map((item, idx) => (
|
||||
<ListItem
|
||||
key={idx}
|
||||
sx={{ py: 1.5, px: 2, mb: 1, borderRadius: '8px', bgcolor: '#f6f7f8', display: 'flex', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ color: '#ac2d00', fontWeight: 800, fontFamily: 'monospace' }}>
|
||||
{item.qty}×
|
||||
{(error || sttError) && (
|
||||
<Alert severity="error" sx={{ width: '100%', textAlign: 'left', fontWeight: 600 }}>
|
||||
{error || sttError}
|
||||
</Alert>
|
||||
)}
|
||||
{shownTranscript && (
|
||||
<Paper elevation={0} sx={{ p: 1.5, width: '100%', bgcolor: '#fff', border: '1px solid #f0ebe7', borderRadius: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068' }}>
|
||||
You said
|
||||
</Typography>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>}
|
||||
secondary={`Subtotal: ₹${(item.qty * item.price)}`}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{shownTranscript}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 7 }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<SmartToyIcon sx={{ color: '#ac2d00' }} />
|
||||
<Typography sx={{ fontWeight: 800 }}>AI reply</Typography>
|
||||
</Box>
|
||||
{assistantText ? (
|
||||
<Alert
|
||||
severity={updates.length ? 'info' : 'success'}
|
||||
icon={<GraphicEqIcon />}
|
||||
sx={{ fontWeight: 600, mb: 1.5 }}
|
||||
>
|
||||
{assistantText}
|
||||
</Alert>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: '#8f7068', mb: 1.5 }}>
|
||||
Try: “Add 2 kg tomato” or “What is low stock?”
|
||||
</Typography>
|
||||
)}
|
||||
{updates.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
{updates.map((row) => (
|
||||
<Chip
|
||||
key={`upd-${row.id}`}
|
||||
color="success"
|
||||
sx={{ fontWeight: 800, mr: 1, mb: 1 }}
|
||||
label={`Updated ${row.name}: ${row.current_stock}${row.unit}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{toolCalls.length > 0 && (
|
||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 700 }}>
|
||||
Tools: {toolCalls.join(' → ')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Type: add 2 kg tomato / check cream stock…"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && typed.trim()) {
|
||||
void runStaffAi(typed);
|
||||
setTyped('');
|
||||
}
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: '12px' } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={busy || !typed.trim()}
|
||||
onClick={() => {
|
||||
void runStaffAi(typed);
|
||||
setTyped('');
|
||||
}}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ fontWeight: 800, bgcolor: '#ac2d00', borderRadius: '12px', px: 2 }}
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{QUICK_PROMPTS.map((p) => (
|
||||
<Chip
|
||||
key={p.label}
|
||||
label={p.label}
|
||||
onClick={() => void runStaffAi(p.text)}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<InventoryIcon sx={{ color: '#ac2d00' }} />
|
||||
<Typography sx={{ fontWeight: 800 }}>Low stock now</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={lowStock.length}
|
||||
color={lowStock.length ? 'error' : 'success'}
|
||||
sx={{ fontWeight: 800 }}
|
||||
/>
|
||||
</Box>
|
||||
<Chip label={item.dietary.toUpperCase()} size="small" variant="outlined" sx={{ fontWeight: 700 }} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<NotificationsActiveIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void runStaffAi('Send a low stock alert notification now')}
|
||||
sx={{ fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||
>
|
||||
Notify
|
||||
</Button>
|
||||
</Box>
|
||||
{lowStock.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#8f7068' }}>
|
||||
No ingredients below reorder threshold.
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense disablePadding>
|
||||
{lowStock.map((row) => (
|
||||
<ListItem
|
||||
key={row.id}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
mb: 0.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: '#fff5f2',
|
||||
border: '1px solid #ffdad6',
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem' }}>{row.name}</Typography>
|
||||
}
|
||||
secondary={`Now ${row.current_stock}${row.unit} · reorder at ${row.reorder_threshold}${row.unit}`}
|
||||
/>
|
||||
<Chip label="LOW" size="small" color="error" sx={{ fontWeight: 800 }} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Total: ₹{totalValue}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={submitted}
|
||||
startIcon={submitted ? <CheckCircleOutlinedIcon /> : <AddShoppingCartIcon />}
|
||||
onClick={handleConfirmOrder}
|
||||
sx={{ fontWeight: 800, px: 4 }}
|
||||
>
|
||||
{submitted ? 'ORDER SENT TO KDS!' : 'DISPATCH TO KDS'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface MenuItem {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
nameHindi?: string;
|
||||
category: 'Starters' | 'Biryani' | 'Mains' | 'Breads' | 'Drinks' | 'Desserts' | 'Specials';
|
||||
|
|
@ -12,12 +12,14 @@ export interface MenuItem {
|
|||
image: string;
|
||||
spiceLevel?: 'mild' | 'medium' | 'hot' | 'extra-hot';
|
||||
tags?: string[];
|
||||
/** Present when loaded from API; static fallback treats missing as available */
|
||||
isAvailable?: boolean;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: MenuItem[] = [
|
||||
// ─── STARTERS ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm1',
|
||||
id: 1,
|
||||
name: 'Samosa Chaat',
|
||||
nameHindi: 'समोसा चाट',
|
||||
category: 'Starters',
|
||||
|
|
@ -31,7 +33,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Popular', 'Street Food'],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
id: 2,
|
||||
name: 'Paneer Tikka',
|
||||
nameHindi: 'पनीर टिक्का',
|
||||
category: 'Starters',
|
||||
|
|
@ -45,7 +47,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ["Chef's Special", 'Tandoor'],
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
id: 3,
|
||||
name: 'Chicken 65',
|
||||
nameHindi: 'चिकन 65',
|
||||
category: 'Starters',
|
||||
|
|
@ -59,7 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['South Indian', 'Crispy'],
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
id: 4,
|
||||
name: 'Hara Bhara Kabab',
|
||||
nameHindi: 'हरा भरा कबाब',
|
||||
category: 'Starters',
|
||||
|
|
@ -72,7 +74,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Healthy', 'Spinach'],
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
id: 5,
|
||||
name: 'Seekh Kebab',
|
||||
nameHindi: 'सीख कबाब',
|
||||
category: 'Starters',
|
||||
|
|
@ -88,7 +90,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── BIRYANI ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm6',
|
||||
id: 6,
|
||||
name: 'Hyderabadi Chicken Biryani',
|
||||
nameHindi: 'हैदराबादी चिकन बिरयानी',
|
||||
category: 'Biryani',
|
||||
|
|
@ -103,7 +105,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Dum Style', 'Hyderabadi', 'Bestseller'],
|
||||
},
|
||||
{
|
||||
id: 'm7',
|
||||
id: 7,
|
||||
name: 'Veg Dum Biryani',
|
||||
nameHindi: 'वेज दम बिरयानी',
|
||||
category: 'Biryani',
|
||||
|
|
@ -116,7 +118,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Dum Style', 'Veg'],
|
||||
},
|
||||
{
|
||||
id: 'm8',
|
||||
id: 8,
|
||||
name: 'Mutton Biryani',
|
||||
nameHindi: 'मटन बिरयानी',
|
||||
category: 'Biryani',
|
||||
|
|
@ -131,7 +133,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── MAINS ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm9',
|
||||
id: 9,
|
||||
name: 'Butter Chicken',
|
||||
nameHindi: 'बटर चिकन',
|
||||
category: 'Mains',
|
||||
|
|
@ -145,7 +147,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Classic', 'Bestseller'],
|
||||
},
|
||||
{
|
||||
id: 'm10',
|
||||
id: 10,
|
||||
name: 'Dal Makhani',
|
||||
nameHindi: 'दाल मखनी',
|
||||
category: 'Mains',
|
||||
|
|
@ -159,7 +161,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Punjabi', 'Classic'],
|
||||
},
|
||||
{
|
||||
id: 'm11',
|
||||
id: 11,
|
||||
name: 'Palak Paneer',
|
||||
nameHindi: 'पालक पनीर',
|
||||
category: 'Mains',
|
||||
|
|
@ -172,7 +174,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Healthy', 'Jain Available'],
|
||||
},
|
||||
{
|
||||
id: 'm12',
|
||||
id: 12,
|
||||
name: 'Chicken Kadhai',
|
||||
nameHindi: 'चिकन कड़ाही',
|
||||
category: 'Mains',
|
||||
|
|
@ -186,7 +188,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Kadhai Style', "Chef's Special"],
|
||||
},
|
||||
{
|
||||
id: 'm13',
|
||||
id: 13,
|
||||
name: 'Paneer Butter Masala',
|
||||
nameHindi: 'पनीर बटर मसाला',
|
||||
category: 'Mains',
|
||||
|
|
@ -199,7 +201,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Popular'],
|
||||
},
|
||||
{
|
||||
id: 'm14',
|
||||
id: 14,
|
||||
name: 'Rogan Josh',
|
||||
nameHindi: 'रोगन जोश',
|
||||
category: 'Mains',
|
||||
|
|
@ -214,7 +216,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── BREADS ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm15',
|
||||
id: 15,
|
||||
name: 'Butter Naan',
|
||||
nameHindi: 'बटर नान',
|
||||
category: 'Breads',
|
||||
|
|
@ -226,7 +228,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Tandoor'],
|
||||
},
|
||||
{
|
||||
id: 'm16',
|
||||
id: 16,
|
||||
name: 'Garlic Naan',
|
||||
nameHindi: 'लहसुन नान',
|
||||
category: 'Breads',
|
||||
|
|
@ -239,7 +241,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Bestseller', 'Tandoor'],
|
||||
},
|
||||
{
|
||||
id: 'm17',
|
||||
id: 17,
|
||||
name: 'Lachha Paratha',
|
||||
nameHindi: 'लच्छा पराठा',
|
||||
category: 'Breads',
|
||||
|
|
@ -253,7 +255,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── DRINKS ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm18',
|
||||
id: 18,
|
||||
name: 'Mango Lassi',
|
||||
nameHindi: 'आम की लस्सी',
|
||||
category: 'Drinks',
|
||||
|
|
@ -266,7 +268,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Summer Special', 'Fresh'],
|
||||
},
|
||||
{
|
||||
id: 'm19',
|
||||
id: 19,
|
||||
name: 'Masala Chai',
|
||||
nameHindi: 'मसाला चाय',
|
||||
category: 'Drinks',
|
||||
|
|
@ -278,7 +280,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Kulhad', 'Hot'],
|
||||
},
|
||||
{
|
||||
id: 'm20',
|
||||
id: 20,
|
||||
name: 'Sweet Lime Soda',
|
||||
nameHindi: 'नींबू सोडा',
|
||||
category: 'Drinks',
|
||||
|
|
@ -290,7 +292,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Fresh', 'Refreshing'],
|
||||
},
|
||||
{
|
||||
id: 'm21',
|
||||
id: 21,
|
||||
name: 'Rose Sharbat',
|
||||
nameHindi: 'गुलाब शरबत',
|
||||
category: 'Drinks',
|
||||
|
|
@ -304,7 +306,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── DESSERTS ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm22',
|
||||
id: 22,
|
||||
name: 'Gulab Jamun',
|
||||
nameHindi: 'गुलाब जामुन',
|
||||
category: 'Desserts',
|
||||
|
|
@ -317,7 +319,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Hot', 'Classic'],
|
||||
},
|
||||
{
|
||||
id: 'm23',
|
||||
id: 23,
|
||||
name: 'Rasmalai',
|
||||
nameHindi: 'रसमलाई',
|
||||
category: 'Desserts',
|
||||
|
|
@ -330,7 +332,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Bengali', "Chef's Special", 'Cold'],
|
||||
},
|
||||
{
|
||||
id: 'm24',
|
||||
id: 24,
|
||||
name: 'Gajar Halwa',
|
||||
nameHindi: 'गाजर का हलवा',
|
||||
category: 'Desserts',
|
||||
|
|
@ -344,7 +346,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
|
||||
// ─── SPECIALS ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'm25',
|
||||
id: 25,
|
||||
name: 'Chef\'s Thali',
|
||||
nameHindi: 'शेफ की थाली',
|
||||
category: 'Specials',
|
||||
|
|
@ -358,7 +360,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||
tags: ['Thali', 'Value', 'Complete Meal'],
|
||||
},
|
||||
{
|
||||
id: 'm26',
|
||||
id: 26,
|
||||
name: 'Non-Veg Thali',
|
||||
nameHindi: 'नॉन-वेज थाली',
|
||||
category: 'Specials',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { debugLog } from '../utils/debugLog';
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList;
|
||||
|
|
@ -43,12 +44,28 @@ interface UseSpeechRecognitionReturn {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
|
||||
type FinalHandler = (text: string) => void;
|
||||
|
||||
/**
|
||||
* Chrome Web Speech (webkitSpeechRecognition).
|
||||
* Submits final text on end; if Chrome only left interim results, submits those too.
|
||||
*/
|
||||
export const useSpeechRecognition = (
|
||||
onFinalTranscript?: FinalHandler
|
||||
): UseSpeechRecognitionReturn => {
|
||||
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
|
||||
const [transcript, setTranscript] = useState<string>('');
|
||||
const [interimTranscript, setInterimTranscript] = useState<string>('');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interimTranscript, setInterimTranscript] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
||||
const finalBufferRef = useRef('');
|
||||
const interimRef = useRef('');
|
||||
const submittedRef = useRef(false);
|
||||
const onFinalRef = useRef<FinalHandler | undefined>(onFinalTranscript);
|
||||
|
||||
useEffect(() => {
|
||||
onFinalRef.current = onFinalTranscript;
|
||||
}, [onFinalTranscript]);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' &&
|
||||
|
|
@ -56,84 +73,160 @@ export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
recognitionRef.current?.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startListening = useCallback((lang: string = 'en-IN') => {
|
||||
const emitFinal = useCallback(() => {
|
||||
if (submittedRef.current) return;
|
||||
const text = (finalBufferRef.current || interimRef.current).trim();
|
||||
if (!text) {
|
||||
debugLog.warn('stt', 'emitFinal skipped — empty buffer');
|
||||
return;
|
||||
}
|
||||
submittedRef.current = true;
|
||||
setTranscript(text);
|
||||
setInterimTranscript('');
|
||||
debugLog.info('stt', 'emitFinal', {
|
||||
source: finalBufferRef.current.trim() ? 'final' : 'interim-fallback',
|
||||
text: text.slice(0, 160),
|
||||
});
|
||||
onFinalRef.current?.(text);
|
||||
}, []);
|
||||
|
||||
const startListening = useCallback(
|
||||
(lang: string = 'en-IN') => {
|
||||
if (!isSupported) {
|
||||
setError('Voice recognition is not supported in this browser. Please use Chrome.');
|
||||
setError('Voice recognition is not supported in this browser.');
|
||||
setVoiceState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recognitionRef.current?.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
finalBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
submittedRef.current = false;
|
||||
setTranscript('');
|
||||
setInterimTranscript('');
|
||||
setError(null);
|
||||
|
||||
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognitionAPI();
|
||||
recognitionRef.current = recognition;
|
||||
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = false;
|
||||
// continuous=true: user taps stop; Chrome is more reliable this way than auto-end.
|
||||
recognition.lang = lang === 'hi-IN' ? 'hi-IN' : 'en-IN';
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
recognition.onstart = () => {
|
||||
debugLog.info('stt', 'recognition onstart', { lang: recognition.lang });
|
||||
setVoiceState('listening');
|
||||
setError(null);
|
||||
setInterimTranscript('');
|
||||
};
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = '';
|
||||
let final = '';
|
||||
let finals = finalBufferRef.current;
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
const piece = result[0]?.transcript || '';
|
||||
if (result.isFinal) {
|
||||
final += result[0].transcript;
|
||||
finals = `${finals} ${piece}`.trim();
|
||||
} else {
|
||||
interim += result[0].transcript;
|
||||
interim += piece;
|
||||
}
|
||||
}
|
||||
|
||||
finalBufferRef.current = finals;
|
||||
interimRef.current = interim;
|
||||
setTranscript(finals);
|
||||
setInterimTranscript(interim);
|
||||
if (final) {
|
||||
setTranscript((prev) => prev + ' ' + final.trim());
|
||||
setVoiceState('thinking');
|
||||
}
|
||||
debugLog.info('stt', 'recognition onresult', {
|
||||
final: finals.slice(0, 120),
|
||||
interim: interim.slice(0, 120),
|
||||
});
|
||||
};
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
// Chrome fires "aborted" when we restart/stop — not a real failure.
|
||||
if (event.error === 'aborted' || event.error === 'no-speech') {
|
||||
debugLog.info('stt', 'recognition soft-error ignored', { error: event.error });
|
||||
return;
|
||||
}
|
||||
debugLog.error('stt', 'recognition onerror', { error: event.error });
|
||||
const errorMessages: Record<string, string> = {
|
||||
'no-speech': 'No speech detected. Please try again.',
|
||||
'audio-capture': 'Microphone not accessible.',
|
||||
'not-allowed': 'Microphone permission denied. Please allow access.',
|
||||
'network': 'Network error. Check your internet connection.',
|
||||
'audio-capture': 'Microphone not accessible. Check Chrome site permissions.',
|
||||
'not-allowed': 'Microphone blocked. Allow mic for localhost in Chrome.',
|
||||
network:
|
||||
'Chrome speech service network error. Use the text box or a quick command.',
|
||||
'service-not-allowed': 'Chrome speech service blocked. Use text / quick command.',
|
||||
};
|
||||
setError(errorMessages[event.error] || `Voice error: ${event.error}`);
|
||||
setVoiceState('error');
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setInterimTranscript('');
|
||||
if (voiceState === 'listening') {
|
||||
setVoiceState('thinking');
|
||||
}
|
||||
debugLog.info('stt', 'recognition onend', {
|
||||
finalBuf: finalBufferRef.current.slice(0, 120),
|
||||
interimBuf: interimRef.current.slice(0, 120),
|
||||
submitted: submittedRef.current,
|
||||
});
|
||||
setVoiceState('idle');
|
||||
emitFinal();
|
||||
};
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (e) {
|
||||
setError('Could not start voice recognition.');
|
||||
debugLog.info('stt', 'recognition.start() called', { lang: recognition.lang, continuous: true });
|
||||
} catch (err) {
|
||||
debugLog.error('stt', 'recognition.start() threw', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
setError('Could not start Chrome speech recognition. Try the text box.');
|
||||
setVoiceState('error');
|
||||
}
|
||||
}, [isSupported, voiceState]);
|
||||
},
|
||||
[emitFinal, isSupported]
|
||||
);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
setVoiceState('thinking');
|
||||
}, []);
|
||||
const recognition = recognitionRef.current;
|
||||
if (!recognition) {
|
||||
emitFinal();
|
||||
setVoiceState('idle');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch {
|
||||
emitFinal();
|
||||
setVoiceState('idle');
|
||||
}
|
||||
// Chrome sometimes delays onend — don't leave the guest hanging.
|
||||
window.setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
emitFinal();
|
||||
setVoiceState('idle');
|
||||
}
|
||||
}, 600);
|
||||
}, [emitFinal]);
|
||||
|
||||
const resetTranscript = useCallback(() => {
|
||||
finalBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
submittedRef.current = false;
|
||||
setTranscript('');
|
||||
setInterimTranscript('');
|
||||
setVoiceState('idle');
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ export const useSpeechSynthesis = (
|
|||
|
||||
const speak = useCallback(
|
||||
(text: string, lang: string = 'en-IN', onEnd?: () => void) => {
|
||||
if (!isSupported) return;
|
||||
if (!isSupported) {
|
||||
onEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
|
@ -24,20 +27,27 @@ export const useSpeechSynthesis = (
|
|||
utterance.pitch = 1.05;
|
||||
utterance.volume = 1;
|
||||
|
||||
utterance.onstart = () => {
|
||||
setVoiceState?.('speaking');
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
setVoiceState?.('idle');
|
||||
onEnd?.();
|
||||
};
|
||||
|
||||
utterance.onerror = () => {
|
||||
setVoiceState?.('idle');
|
||||
utterance.onstart = () => {
|
||||
setVoiceState?.('speaking');
|
||||
};
|
||||
utterance.onend = finish;
|
||||
utterance.onerror = finish;
|
||||
|
||||
try {
|
||||
window.speechSynthesis.speak(utterance);
|
||||
// Firefox can leave speech pending without onend — unblock UI.
|
||||
window.setTimeout(finish, 5000);
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
},
|
||||
[isSupported, setVoiceState]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
12
src/main.tsx
12
src/main.tsx
|
|
@ -1,10 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import { CustomerApp } from './apps/customer/App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<CustomerApp />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,44 +1,146 @@
|
|||
import { type MenuItem, MENU_ITEMS } from '../data/menuData';
|
||||
import { type MenuItem, MENU_ITEMS, CATEGORIES } from '../data/menuData';
|
||||
import type { CategoryType } from '../data/menuData';
|
||||
import { debugLog, maskToken } from '../utils/debugLog';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
|
||||
const DEMO_FALLBACK = import.meta.env.VITE_DEMO_FALLBACK === 'true';
|
||||
|
||||
export type PublicMenuItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
name_hindi?: string | null;
|
||||
description?: string | null;
|
||||
price: number | string;
|
||||
prep_time_minutes?: number | null;
|
||||
dietary_type?: string | null;
|
||||
image_url?: string | null;
|
||||
spice_level?: string | null;
|
||||
is_available: boolean;
|
||||
is_chef_special: boolean;
|
||||
is_bestseller: boolean;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type PublicMenuCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_order: number;
|
||||
items: PublicMenuItem[];
|
||||
};
|
||||
|
||||
export type PublicMenuResponse = {
|
||||
restaurant_id: number;
|
||||
updated_at?: string | null;
|
||||
categories: PublicMenuCategory[];
|
||||
};
|
||||
|
||||
export type StaffMenuItem = {
|
||||
id: number;
|
||||
restaurant_id: number;
|
||||
category_id: number | null;
|
||||
category_name?: string | null;
|
||||
name: string;
|
||||
name_hindi?: string | null;
|
||||
description?: string | null;
|
||||
price: number | string;
|
||||
gst_rate: number | string;
|
||||
prep_time_minutes?: number | null;
|
||||
dietary_type?: string | null;
|
||||
image_url?: string | null;
|
||||
spice_level?: string | null;
|
||||
is_available: boolean;
|
||||
is_chef_special: boolean;
|
||||
is_bestseller: boolean;
|
||||
tags: { id: number; tag_type: string; value: string }[];
|
||||
};
|
||||
|
||||
export type StaffMenuCategory = {
|
||||
id: number;
|
||||
restaurant_id: number;
|
||||
name: string;
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
const toUiDietary = (value?: string | null): MenuItem['dietary'] => {
|
||||
if (value === 'non_veg' || value === 'non-veg') return 'non-veg';
|
||||
if (value === 'vegan' || value === 'jain' || value === 'veg') return value;
|
||||
return 'veg';
|
||||
};
|
||||
|
||||
export const flattenPublicMenu = (catalog: PublicMenuResponse): MenuItem[] => {
|
||||
return catalog.categories.flatMap((category) =>
|
||||
category.items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
nameHindi: item.name_hindi || undefined,
|
||||
category: (CATEGORIES.includes(category.name as CategoryType)
|
||||
? category.name
|
||||
: 'Specials') as MenuItem['category'],
|
||||
price: Number(item.price),
|
||||
prepTimeMinutes: item.prep_time_minutes ?? 10,
|
||||
dietary: toUiDietary(item.dietary_type),
|
||||
isChefSpecial: item.is_chef_special,
|
||||
isBestseller: item.is_bestseller,
|
||||
description: item.description || '',
|
||||
image: item.image_url || '',
|
||||
spiceLevel: (item.spice_level as MenuItem['spiceLevel']) || undefined,
|
||||
tags: item.tags || [],
|
||||
isAvailable: item.is_available,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
// Helpers to get/set auth tokens
|
||||
const getAccessToken = () => localStorage.getItem('access_token');
|
||||
export { getAccessToken };
|
||||
export const getRefreshToken = () => localStorage.getItem('refresh_token');
|
||||
const setTokens = (access: string, refresh: string) => {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
};
|
||||
const clearTokens = () => {
|
||||
export const clearTokens = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
};
|
||||
|
||||
const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
|
||||
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id');
|
||||
const setCustomerSession = (token: string, sessionId: string, tableId: string, restaurantId: string) => {
|
||||
const setCustomerSession = (
|
||||
token: string,
|
||||
sessionId: string,
|
||||
tableId: string,
|
||||
restaurantId: string,
|
||||
tableNumber?: string,
|
||||
) => {
|
||||
sessionStorage.setItem('customer_session_token', token);
|
||||
sessionStorage.setItem('customer_session_id', sessionId);
|
||||
sessionStorage.setItem('customer_table_id', tableId);
|
||||
sessionStorage.setItem('customer_restaurant_id', restaurantId);
|
||||
if (tableNumber) {
|
||||
sessionStorage.setItem('customer_table_number', tableNumber);
|
||||
}
|
||||
};
|
||||
export const clearCustomerSession = () => {
|
||||
sessionStorage.removeItem('customer_session_token');
|
||||
sessionStorage.removeItem('customer_session_id');
|
||||
sessionStorage.removeItem('customer_table_id');
|
||||
sessionStorage.removeItem('customer_restaurant_id');
|
||||
sessionStorage.removeItem('customer_table_number');
|
||||
};
|
||||
|
||||
// Map front-end item ID (e.g. 'm1') to back-end numeric ID
|
||||
export const getBackendMenuId = (frontendId: string): number => {
|
||||
return parseInt(frontendId.replace(/[^0-9]/g, '')) || 1;
|
||||
/** @deprecated Prefer numeric catalog ids from GET /menu/public */
|
||||
export const getBackendMenuId = (frontendId: string | number): number => {
|
||||
if (typeof frontendId === 'number') return frontendId;
|
||||
return parseInt(String(frontendId).replace(/[^0-9]/g, ''), 10) || 1;
|
||||
};
|
||||
|
||||
// Map back-end ID to front-end menu item
|
||||
export const getFrontendMenuItem = (backendId: number | string): MenuItem | undefined => {
|
||||
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId)) || 1;
|
||||
return MENU_ITEMS.find(m => m.id === `m${numId}`) || MENU_ITEMS[0];
|
||||
export const getFrontendMenuItem = (
|
||||
backendId: number | string,
|
||||
catalog: MenuItem[] = MENU_ITEMS,
|
||||
): MenuItem | undefined => {
|
||||
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId), 10) || 1;
|
||||
return catalog.find((m) => m.id === numId) || catalog[0];
|
||||
};
|
||||
|
||||
// Connection State tracking
|
||||
|
|
@ -107,6 +209,13 @@ const mockDb = {
|
|||
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const headers = new Headers(options.headers || {});
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const isAiOrSession =
|
||||
path.startsWith('/ai') ||
|
||||
path.includes('/start-session') ||
|
||||
path.startsWith('/tables/public') ||
|
||||
path.startsWith('/menu/public');
|
||||
const started = performance.now();
|
||||
|
||||
// Inject Staff Auth Token
|
||||
const staffToken = getAccessToken();
|
||||
|
|
@ -124,13 +233,27 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
|
|||
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
// Let the browser set multipart boundary for FormData
|
||||
if (options.body instanceof FormData && headers.has('Content-Type')) {
|
||||
headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
if (isAiOrSession) {
|
||||
debugLog.info('api', `${method} ${path} →`, {
|
||||
hasSession: Boolean(customerToken),
|
||||
sessionToken: maskToken(customerToken),
|
||||
hasStaff: Boolean(staffToken),
|
||||
bodyPreview:
|
||||
typeof options.body === 'string' ? options.body.slice(0, 180) : options.body ? '(FormData)' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
triggerDemoMode(false);
|
||||
|
||||
if (res.status === 401 && staffToken) {
|
||||
// Token expired, clear it
|
||||
if (res.status === 401 && staffToken && !customerToken) {
|
||||
// Only clear staff auth when this was a staff-only request.
|
||||
clearTokens();
|
||||
window.location.reload();
|
||||
throw new Error('Staff session expired. Please log in again.');
|
||||
|
|
@ -144,11 +267,49 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
|
|||
} catch {
|
||||
parsedErr = { error: errText };
|
||||
}
|
||||
throw new Error(parsedErr.error || parsedErr.message || `Request failed: ${res.status}`);
|
||||
const detail = parsedErr.detail;
|
||||
const detailText =
|
||||
typeof detail === 'string'
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((d: { msg?: string }) => d.msg || JSON.stringify(d)).join('; ')
|
||||
: detail
|
||||
? JSON.stringify(detail)
|
||||
: '';
|
||||
const message =
|
||||
detailText ||
|
||||
parsedErr.error ||
|
||||
parsedErr.message ||
|
||||
`Request failed: ${res.status}`;
|
||||
if (isAiOrSession) {
|
||||
debugLog.error('api', `${method} ${path} failed`, {
|
||||
status: res.status,
|
||||
ms: Math.round(performance.now() - started),
|
||||
detail: String(message).slice(0, 300),
|
||||
});
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
const json = await res.json();
|
||||
if (isAiOrSession) {
|
||||
debugLog.info('api', `${method} ${path} ok`, {
|
||||
status: res.status,
|
||||
ms: Math.round(performance.now() - started),
|
||||
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
|
||||
});
|
||||
}
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (isAiOrSession) {
|
||||
debugLog.error('api', `${method} ${path} exception`, {
|
||||
ms: Math.round(performance.now() - started),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
if (!DEMO_FALLBACK) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
|
||||
triggerDemoMode(true);
|
||||
return handleMockRequest(path, options);
|
||||
|
|
@ -317,9 +478,47 @@ function handleMockRequest(path: string, options: RequestInit): any {
|
|||
return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
|
||||
}
|
||||
|
||||
// 7. KDS Board
|
||||
// 7. KDS Board + staff test order
|
||||
if (path.startsWith('/kds/')) {
|
||||
return mockDb.orders;
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
// /kds/{id}/test-order
|
||||
if (parts.length >= 3 && parts[2] === 'test-order' && method === 'POST') {
|
||||
const newOrder = {
|
||||
id: `ord-mock-${Date.now()}`,
|
||||
order_id: Date.now(),
|
||||
ticketNumber: String(100 + mockDb.orders.length + 1),
|
||||
table_number: 'T1',
|
||||
channel: 'manual_dine_in',
|
||||
orderType: 'Dine-In',
|
||||
status: 'placed',
|
||||
placed_at: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
timeElapsedMinutes: 0,
|
||||
items: [
|
||||
{
|
||||
id: `oi-${Date.now()}-1`,
|
||||
menu_item_id: 9,
|
||||
name: 'Butter Chicken',
|
||||
quantity: 1,
|
||||
unit_price: 380,
|
||||
kds_status: 'queued',
|
||||
},
|
||||
{
|
||||
id: `oi-${Date.now()}-2`,
|
||||
menu_item_id: 16,
|
||||
name: 'Garlic Naan',
|
||||
quantity: 2,
|
||||
unit_price: 60,
|
||||
kds_status: 'queued',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockDb.orders.push(newOrder);
|
||||
notifyWsListeners({ type: 'kds.item_updated' });
|
||||
return newOrder;
|
||||
}
|
||||
// /kds/{id}/board
|
||||
return { restaurant_id: Number(parts[1]) || 1, orders: mockDb.orders };
|
||||
}
|
||||
|
||||
// Patch KDS order item status
|
||||
|
|
@ -555,11 +754,16 @@ const notifyWsListeners = (event: any) => {
|
|||
});
|
||||
};
|
||||
|
||||
export type WsConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||
|
||||
export class RestroWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private reconnectTimer: any = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isClosedIntentional = false;
|
||||
private hadOpenConnection = false;
|
||||
onStateChange: ((state: WsConnectionState) => void) | null = null;
|
||||
onReconnected: (() => void) | null = null;
|
||||
|
||||
constructor(channel: 'kds' | 'orders', param: string) {
|
||||
if (channel === 'kds') {
|
||||
|
|
@ -571,15 +775,30 @@ export class RestroWebSocket {
|
|||
}
|
||||
}
|
||||
|
||||
private setState(state: WsConnectionState) {
|
||||
this.onStateChange?.(state);
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (isDemoMode) {
|
||||
console.log('Skipping real WebSocket connection in Demo Mode');
|
||||
this.setState('open');
|
||||
return;
|
||||
}
|
||||
this.isClosedIntentional = false;
|
||||
this.setState(this.hadOpenConnection ? 'reconnecting' : 'connecting');
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
const reconnected = this.hadOpenConnection;
|
||||
this.hadOpenConnection = true;
|
||||
this.setState('open');
|
||||
if (reconnected) {
|
||||
this.onReconnected?.();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
|
@ -592,7 +811,10 @@ export class RestroWebSocket {
|
|||
this.ws.onclose = () => {
|
||||
if (!this.isClosedIntentional) {
|
||||
console.log('WebSocket closed. Attempting reconnect in 5s...');
|
||||
this.setState('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||
} else {
|
||||
this.setState('closed');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -602,6 +824,8 @@ export class RestroWebSocket {
|
|||
};
|
||||
} catch (e) {
|
||||
console.error('WebSocket connection setup failed', e);
|
||||
this.setState('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -612,6 +836,7 @@ export class RestroWebSocket {
|
|||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.setState('closed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -646,19 +871,143 @@ export const api = {
|
|||
return !!getAccessToken();
|
||||
},
|
||||
|
||||
getMe: async (): Promise<{
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
restaurant_id: number;
|
||||
home_restaurant_id?: number;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
restaurants?: { id: number; name: string; is_home: boolean; is_active: boolean }[];
|
||||
}> => {
|
||||
return apiRequest('/auth/me');
|
||||
},
|
||||
|
||||
switchRestaurant: async (restaurantId: number) => {
|
||||
const res = await apiRequest('/auth/switch-restaurant', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ restaurant_id: restaurantId }),
|
||||
});
|
||||
if (res.access_token) {
|
||||
setTokens(res.access_token, res.refresh_token);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
aiChatTurn: async (payload: {
|
||||
transcript: string;
|
||||
language?: string;
|
||||
submit?: boolean;
|
||||
}): Promise<{
|
||||
transcript: string;
|
||||
assistant_text: string;
|
||||
cart: {
|
||||
menu_item_id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit_price: number | string;
|
||||
notes: string[];
|
||||
is_available: boolean;
|
||||
}[];
|
||||
order: { order_id: number; subtotal: number | string; channel: string; item_count: number } | null;
|
||||
tool_calls: string[];
|
||||
}> => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), 25_000);
|
||||
try {
|
||||
return await apiRequest('/ai/chat-turn', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('AI is taking too long — please try again in a moment.');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
|
||||
/** Staff JWT: inventory / kitchen assistant. */
|
||||
staffAiChatTurn: async (payload: {
|
||||
transcript: string;
|
||||
language?: string;
|
||||
}): Promise<{
|
||||
transcript: string;
|
||||
assistant_text: string;
|
||||
tool_calls: string[];
|
||||
low_stock: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
current_stock: number;
|
||||
reorder_threshold: number;
|
||||
is_low: boolean;
|
||||
}[];
|
||||
updates?: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
current_stock: number;
|
||||
reorder_threshold: number;
|
||||
is_low: boolean;
|
||||
}[];
|
||||
notify?: Record<string, unknown> | null;
|
||||
}> => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), 25_000);
|
||||
try {
|
||||
return await apiRequest('/ai/staff-chat-turn', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Staff AI is taking too long — please try again.');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
|
||||
// Tables & Session
|
||||
startSession: async (tableId: number, language = 'en') => {
|
||||
startSession: async (tableId: number, language = 'en', tableNumber?: string) => {
|
||||
const res = await apiRequest(`/tables/${tableId}/start-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ language })
|
||||
});
|
||||
|
||||
if (res.session_token) {
|
||||
setCustomerSession(res.session_token, String(res.session_id), String(res.table_id), String(res.restaurant_id));
|
||||
setCustomerSession(
|
||||
res.session_token,
|
||||
String(res.session_id),
|
||||
String(res.table_id),
|
||||
String(res.restaurant_id),
|
||||
tableNumber,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
/** Guest table picker (no auth). Defaults to first restaurant. */
|
||||
getPublicTables: async (opts?: {
|
||||
restaurantId?: number;
|
||||
availableOnly?: boolean;
|
||||
}): Promise<
|
||||
{ id: number; restaurant_id: number; table_number: string; capacity: number; status: string }[]
|
||||
> => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.restaurantId != null) params.set('restaurant_id', String(opts.restaurantId));
|
||||
if (opts?.availableOnly === false) params.set('available_only', 'false');
|
||||
const qs = params.toString() ? `?${params}` : '';
|
||||
return apiRequest(`/tables/public${qs}`);
|
||||
},
|
||||
|
||||
closeSession: async (tableId: number) => {
|
||||
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' });
|
||||
},
|
||||
|
|
@ -694,8 +1043,17 @@ export const api = {
|
|||
});
|
||||
},
|
||||
|
||||
/** Staff KDS: create a sample ticket without a guest session. */
|
||||
createKdsTestOrder: async (restaurantId: string | number) => {
|
||||
return apiRequest(`/kds/${restaurantId}/test-order`, { method: 'POST' });
|
||||
},
|
||||
|
||||
getOrderForSession: async (sessionId: string) => {
|
||||
return apiRequest(`/orders/${sessionId}`);
|
||||
const data = await apiRequest(`/orders/${sessionId}`);
|
||||
if (Array.isArray(data)) {
|
||||
return data.length ? data[data.length - 1] : null;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
getStaffOrderForSession: async (sessionId: string) => {
|
||||
|
|
@ -718,10 +1076,39 @@ export const api = {
|
|||
},
|
||||
|
||||
// Billing
|
||||
generateBill: async (orderId: string) => {
|
||||
generateBill: async (orderId: string | number) => {
|
||||
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/** Customer session: generate or return existing bill for an order. */
|
||||
sessionGenerateBill: async (orderId: string | number) => {
|
||||
return apiRequest(`/session/orders/${orderId}/generate-bill`, { method: 'POST' });
|
||||
},
|
||||
|
||||
sessionGetBill: async (billId: string | number) => {
|
||||
return apiRequest(`/session/bills/${billId}`);
|
||||
},
|
||||
|
||||
sessionCheckoutRazorpay: async (billId: string | number) => {
|
||||
return apiRequest(`/session/bills/${billId}/checkout/razorpay`, { method: 'POST' });
|
||||
},
|
||||
|
||||
sessionConfirmRazorpay: async (
|
||||
billId: string | number,
|
||||
payload: {
|
||||
razorpay_order_id: string;
|
||||
razorpay_payment_id: string;
|
||||
razorpay_signature: string;
|
||||
amount?: number;
|
||||
method?: string;
|
||||
}
|
||||
) => {
|
||||
return apiRequest(`/session/bills/${billId}/confirm-razorpay`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
recordPayment: async (billId: string, method: string, amount: number) => {
|
||||
return apiRequest(`/bills/${billId}/record-payment`, {
|
||||
method: 'POST',
|
||||
|
|
@ -766,6 +1153,10 @@ export const api = {
|
|||
return apiRequest('/inventory/low-stock');
|
||||
},
|
||||
|
||||
notifyLowStock: async () => {
|
||||
return apiRequest('/inventory/notify-low-stock', { method: 'POST' });
|
||||
},
|
||||
|
||||
// Recipes
|
||||
getRecipe: async (menuItemId: number) => {
|
||||
return apiRequest(`/menu-items/${menuItemId}/recipe`);
|
||||
|
|
@ -779,6 +1170,65 @@ export const api = {
|
|||
});
|
||||
},
|
||||
|
||||
// Menu catalog
|
||||
getPublicMenu: async (): Promise<PublicMenuResponse> => {
|
||||
const known =
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('menu_catalog_updated_at') || ''
|
||||
: '';
|
||||
const qs = known ? `?updated_at=${encodeURIComponent(known)}` : '';
|
||||
const data = (await apiRequest(`/menu/public${qs}`)) as PublicMenuResponse;
|
||||
if (data?.updated_at && typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('menu_catalog_updated_at', data.updated_at);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
listMenuCategories: async (): Promise<StaffMenuCategory[]> => {
|
||||
return apiRequest('/menu/categories');
|
||||
},
|
||||
|
||||
createMenuCategory: async (payload: { name: string; display_order?: number }) => {
|
||||
return apiRequest('/menu/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
updateMenuCategory: async (id: number, payload: { name?: string; display_order?: number }) => {
|
||||
return apiRequest(`/menu/categories/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
deleteMenuCategory: async (id: number) => {
|
||||
return apiRequest(`/menu/categories/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
listMenuItems: async (categoryId?: number): Promise<StaffMenuItem[]> => {
|
||||
const qs = categoryId != null ? `?category_id=${categoryId}` : '';
|
||||
return apiRequest(`/menu/items${qs}`);
|
||||
},
|
||||
|
||||
createMenuItem: async (payload: Record<string, unknown>): Promise<StaffMenuItem> => {
|
||||
return apiRequest('/menu/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
updateMenuItem: async (id: number, payload: Record<string, unknown>): Promise<StaffMenuItem> => {
|
||||
return apiRequest(`/menu/items/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
deleteMenuItem: async (id: number) => {
|
||||
return apiRequest(`/menu/items/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// Suppliers CRUD
|
||||
getSuppliers: async () => {
|
||||
return apiRequest('/suppliers');
|
||||
|
|
|
|||
|
|
@ -1,145 +1,2 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#ac2d00',
|
||||
light: '#ffb5a0',
|
||||
dark: '#872100',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#546067',
|
||||
light: '#818e95',
|
||||
dark: '#2a363d',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
default: '#f8f9fa',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
error: {
|
||||
main: '#ba1a1a',
|
||||
light: '#ffdad6',
|
||||
dark: '#93000a',
|
||||
},
|
||||
warning: {
|
||||
main: '#845000',
|
||||
light: '#ffddba',
|
||||
dark: '#2b1700',
|
||||
},
|
||||
success: {
|
||||
main: '#11651d',
|
||||
light: '#a3f69c',
|
||||
dark: '#003915',
|
||||
},
|
||||
info: {
|
||||
main: '#00a6e0',
|
||||
light: '#c4e7ff',
|
||||
dark: '#00374d',
|
||||
},
|
||||
text: {
|
||||
primary: '#1a1c1c',
|
||||
secondary: '#5b4139',
|
||||
},
|
||||
divider: '#e4beb4',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
h1: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h3: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
||||
fontWeight: 700,
|
||||
},
|
||||
h4: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
body1: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body2: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
lineHeight: 1.43,
|
||||
},
|
||||
button: {
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
},
|
||||
caption: {
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
padding: '8px 16px',
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0px 2px 8px rgba(172, 45, 0, 0.25)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0px 2px 12px rgba(0, 0, 0, 0.05)',
|
||||
border: '1px solid rgba(228, 190, 180, 0.4)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
borderRadius: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1a1c1c',
|
||||
boxShadow: '0px 1px 10px rgba(0,0,0,0.05)',
|
||||
borderBottom: '1px solid #e2e2e2',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
/** Re-export shared theme from @restroai/ui. */
|
||||
export { theme } from '@restroai/ui';
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
|
@ -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>
|
||||
|
|
@ -4,11 +4,16 @@
|
|||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@restroai/ui": ["packages/ui/src/index.ts"],
|
||||
"@restroai/ui/*": ["packages/ui/src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
|
@ -16,11 +21,10 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "packages/ui/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,86 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'node:path';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons.svg'],
|
||||
manifest: {
|
||||
name: 'RestroAI Guest',
|
||||
short_name: 'RestroAI',
|
||||
description: 'Scan, order, and pay at your table',
|
||||
theme_color: '#ac2d00',
|
||||
background_color: '#fff9f7',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/staff\.html/, /^\/api/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.includes('/menu/public'),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'restroai-menu',
|
||||
expiration: {
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'restroai-images',
|
||||
expiration: {
|
||||
maxEntries: 60,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Dev uses Vite HMR; SW/precache only in production builds (avoids empty dev-dist warning).
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@restroai/ui': path.resolve(__dirname, 'packages/ui/src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'index.html'),
|
||||
staff: path.resolve(__dirname, 'staff.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue