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
|
# RestroAI Frontend Environment Variables
|
||||||
|
|
||||||
# The HTTP URL of the remote backend server
|
# Local full stack (docker compose up from retro_backend repo)
|
||||||
VITE_API_URL=https://api.yourdomain.com
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_WS_URL=ws://localhost:8000
|
||||||
|
|
||||||
# The WebSocket URL of the remote backend server
|
# Production
|
||||||
VITE_WS_URL=wss://api.yourdomain.com
|
# VITE_API_URL=https://restro-backend.navigolabs.com
|
||||||
|
# VITE_WS_URL=wss://restro-backend.navigolabs.com
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>RestroAI Operations Platform - Kitchen & Analytics Suite</title>
|
<meta name="theme-color" content="#ac2d00" />
|
||||||
<!-- Google Fonts: Inter, Plus Jakarta Sans & JetBrains Mono -->
|
<meta name="description" content="RestroAI guest ordering — scan, order, pay at your table" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons.svg" />
|
||||||
|
<title>RestroAI — Table Ordering</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/apps/customer/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,6 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:customer": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|
@ -16,7 +17,10 @@
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"workbox-window": "^7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
|
|
|
||||||
|
|
@ -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';
|
/** @deprecated Use CustomerApp from src/apps/customer — kept for tooling that imports ./App */
|
||||||
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
|
export { CustomerApp as default, CustomerApp as App } from './apps/customer/App';
|
||||||
import { theme } from './theme/theme';
|
|
||||||
import { TableLandingView } from './components/customer/TableLandingView';
|
|
||||||
import { MenuBrowseView } from './components/customer/MenuBrowseView';
|
|
||||||
import { CustomizationModal } from './components/customer/CustomizationModal';
|
|
||||||
import { CartReviewView } from './components/customer/CartReviewView';
|
|
||||||
import { OrderStatusView } from './components/customer/OrderStatusView';
|
|
||||||
import { BillPaymentView } from './components/customer/BillPaymentView';
|
|
||||||
import { VoiceAssistantModal } from './components/customer/VoiceAssistantModal';
|
|
||||||
import { StaffLoginModal } from './components/admin/StaffLoginModal';
|
|
||||||
import { AdminDesktopShell } from './components/admin/AdminDesktopShell';
|
|
||||||
import type { MenuItem } from './data/menuData';
|
|
||||||
import type { KDSOrder, OrderStatus } from './types';
|
|
||||||
import { api, getBackendMenuId, getFrontendMenuItem, RestroWebSocket, subscribeToWsEvents, isDemoMode, setDemoModeChangeCallback } from './services/api';
|
|
||||||
|
|
||||||
type CustomerView = 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice';
|
|
||||||
|
|
||||||
const getTableNumFromUrl = (): number => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const tableParam = params.get('table');
|
|
||||||
if (tableParam) {
|
|
||||||
const parsed = parseInt(tableParam);
|
|
||||||
if (!isNaN(parsed)) return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const match = path.match(/\/scan\/(\d+)/);
|
|
||||||
if (match) {
|
|
||||||
return parseInt(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 12;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
|
||||||
const [appMode, setAppMode] = useState<'customer' | 'staff'>('customer');
|
|
||||||
const [customerView, setCustomerView] = useState<CustomerView>('landing');
|
|
||||||
const [staffRole, setStaffRole] = useState<'kitchen' | 'manager'>('kitchen');
|
|
||||||
const [staffLoginOpen, setStaffLoginOpen] = useState<boolean>(false);
|
|
||||||
const [voiceModalOpen, setVoiceModalOpen] = useState<boolean>(false);
|
|
||||||
const [demoActive, setDemoActive] = useState<boolean>(isDemoMode);
|
|
||||||
|
|
||||||
const [cart, setCart] = useState<{ [itemId: string]: number }>({});
|
|
||||||
const [customizingItem, setCustomizingItem] = useState<MenuItem | null>(null);
|
|
||||||
const [orders, setOrders] = useState<KDSOrder[]>([]);
|
|
||||||
|
|
||||||
// Monitor demo mode status
|
|
||||||
useEffect(() => {
|
|
||||||
setDemoActive(isDemoMode);
|
|
||||||
setDemoModeChangeCallback((demo) => {
|
|
||||||
setDemoActive(demo);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch KDS board when staff logged in
|
|
||||||
const loadKDS = async () => {
|
|
||||||
try {
|
|
||||||
// Backend returns KdsBoardResponse: { restaurant_id, orders: KdsBoardOrder[] }
|
|
||||||
const data = await api.getKDSBoard(1); // 1 = restaurant ID
|
|
||||||
const ordersArray = Array.isArray(data) ? data : (data?.orders || []);
|
|
||||||
|
|
||||||
// Map API Order model to KDSOrder type if format differs
|
|
||||||
const mappedOrders: KDSOrder[] = ordersArray.map((ord: any) => {
|
|
||||||
// Map KDS status to Order status
|
|
||||||
let overallStatus: OrderStatus = 'pending';
|
|
||||||
if (ord.status === 'served') overallStatus = 'served';
|
|
||||||
else if (ord.status === 'ready') overallStatus = 'ready';
|
|
||||||
else if (ord.status === 'in_preparation' || ord.status === 'preparing') overallStatus = 'preparing';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(ord.id || ord.order_id),
|
|
||||||
ticketNumber: ord.ticketNumber || String(ord.id || ord.order_id).substring(0, 4),
|
|
||||||
tableNumber: ord.tableNumber || (ord.table_number ? String(ord.table_number) : 'Takeaway'),
|
|
||||||
orderType: ord.orderType || (ord.channel === 'touch' ? 'Dine-In' : 'Dine-In'),
|
|
||||||
status: overallStatus,
|
|
||||||
createdAt: ord.createdAt || ord.placed_at || new Date().toISOString(),
|
|
||||||
timeElapsedMinutes: ord.timeElapsedMinutes || 0,
|
|
||||||
priority: ord.priority || 'normal',
|
|
||||||
serverName: ord.serverName || 'System',
|
|
||||||
totalAmount: ord.totalAmount || Number(ord.subtotal || 0),
|
|
||||||
items: ord.items?.map((it: any) => {
|
|
||||||
const original = getFrontendMenuItem(it.menu_item_id);
|
|
||||||
return {
|
|
||||||
id: String(it.id),
|
|
||||||
name: it.name || original?.name || `Dish ${it.menu_item_id}`,
|
|
||||||
quantity: it.quantity,
|
|
||||||
price: it.price || (it.unit_price ? Number(it.unit_price) : (original?.price || 150)),
|
|
||||||
completed: it.kds_status === 'ready' || it.kds_status === 'served',
|
|
||||||
dietary: 'veg'
|
|
||||||
};
|
|
||||||
}) || []
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setOrders(mappedOrders);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to reload KDS board', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (appMode === 'staff') {
|
|
||||||
loadKDS();
|
|
||||||
|
|
||||||
// Setup WebSocket connection
|
|
||||||
const ws = new RestroWebSocket('kds', '1');
|
|
||||||
ws.connect();
|
|
||||||
|
|
||||||
// Subscribe to updates
|
|
||||||
const unsubscribe = subscribeToWsEvents((event) => {
|
|
||||||
if (event.type === 'kds.item_updated') {
|
|
||||||
loadKDS();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ws.close();
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [appMode]);
|
|
||||||
|
|
||||||
const handleUpdateCart = (itemId: string, quantity: number) => {
|
|
||||||
setCart((prev) => {
|
|
||||||
const copy = { ...prev };
|
|
||||||
if (quantity <= 0) {
|
|
||||||
delete copy[itemId];
|
|
||||||
} else {
|
|
||||||
copy[itemId] = quantity;
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomizationConfirm = (item: MenuItem, quantity: number, _notes: string) => {
|
|
||||||
handleUpdateCart(item.id, (cart[item.id] || 0) + quantity);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartOrdering = async (language: string) => {
|
|
||||||
try {
|
|
||||||
const tableId = getTableNumFromUrl();
|
|
||||||
await api.startSession(tableId, language === 'english' ? 'en' : 'hi');
|
|
||||||
setCustomerView('menu');
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Could not initialize session, entering offline menu browse', err);
|
|
||||||
setCustomerView('menu');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlaceOrder = async (_orderNotes: string) => {
|
|
||||||
try {
|
|
||||||
const items = Object.entries(cart).map(([id, qty]) => ({
|
|
||||||
menu_item_id: getBackendMenuId(id),
|
|
||||||
quantity: qty,
|
|
||||||
customization_notes: []
|
|
||||||
}));
|
|
||||||
|
|
||||||
await api.placeOrder(items);
|
|
||||||
setCart({});
|
|
||||||
setCustomerView('status');
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to place order: ' + err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
|
|
||||||
try {
|
|
||||||
const statusMap: Record<OrderStatus, string> = {
|
|
||||||
pending: 'queued',
|
|
||||||
preparing: 'in_prep',
|
|
||||||
ready: 'ready',
|
|
||||||
served: 'served'
|
|
||||||
};
|
|
||||||
const kdsStatus = statusMap[newStatus];
|
|
||||||
|
|
||||||
const order = orders.find((o) => o.id === orderId);
|
|
||||||
if (order) {
|
|
||||||
for (const item of order.items) {
|
|
||||||
await api.updateOrderItemStatus(item.id, kdsStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadKDS();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to change status', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleItem = async (orderId: string, itemId: string) => {
|
|
||||||
try {
|
|
||||||
const order = orders.find((o) => o.id === orderId);
|
|
||||||
const item = order?.items.find((i) => i.id === itemId);
|
|
||||||
if (item) {
|
|
||||||
const nextStatus = item.completed ? 'in_prep' : 'ready';
|
|
||||||
await api.updateOrderItemStatus(itemId, nextStatus);
|
|
||||||
loadKDS();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle item', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSampleOrder = async () => {
|
|
||||||
// Add a sample order via API or locally
|
|
||||||
try {
|
|
||||||
const items = [
|
|
||||||
{ menu_item_id: 9, quantity: 2 }, // Butter chicken
|
|
||||||
{ menu_item_id: 11, quantity: 4 } // Garlic naan
|
|
||||||
];
|
|
||||||
await api.placeOrder(items);
|
|
||||||
loadKDS();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to add sample order', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddOrderFromVoice = async (_transcript: string) => {
|
|
||||||
try {
|
|
||||||
const items = [
|
|
||||||
{ menu_item_id: 9, quantity: 2, customization_notes: ['Via Voice assistant'] },
|
|
||||||
{ menu_item_id: 11, quantity: 3 }
|
|
||||||
];
|
|
||||||
await api.placeOrder(items);
|
|
||||||
loadKDS();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to place voice order', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStaffLoginSuccess = (role: 'kitchen' | 'manager') => {
|
|
||||||
setStaffRole(role);
|
|
||||||
setAppMode('staff');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
|
|
||||||
{demoActive && (
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
variant="filled"
|
|
||||||
sx={{
|
|
||||||
borderRadius: 0,
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontWeight: 800,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
zIndex: 2000,
|
|
||||||
background: 'linear-gradient(90deg, #d32f2f 0%, #ef5350 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Running in Demo Mode (Mock Backend). Start restroai-backend locally to connect to live DB.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{appMode === 'staff' ? (
|
|
||||||
<AdminDesktopShell
|
|
||||||
role={staffRole}
|
|
||||||
orders={orders}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
onToggleItem={handleToggleItem}
|
|
||||||
onAddSampleOrder={handleAddSampleOrder}
|
|
||||||
onAddOrderFromVoice={handleAddOrderFromVoice}
|
|
||||||
onLogout={() => setAppMode('customer')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2' }}>
|
|
||||||
{customerView === 'landing' && (
|
|
||||||
<TableLandingView
|
|
||||||
onStartOrdering={handleStartOrdering}
|
|
||||||
onOpenStaffLogin={() => setStaffLoginOpen(true)}
|
|
||||||
onOpenVoice={() => setVoiceModalOpen(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customerView === 'menu' && (
|
|
||||||
<MenuBrowseView
|
|
||||||
cart={cart}
|
|
||||||
onUpdateCart={handleUpdateCart}
|
|
||||||
onOpenCustomization={(item) => setCustomizingItem(item)}
|
|
||||||
onOpenVoice={() => setVoiceModalOpen(true)}
|
|
||||||
onNavigate={(v) => setCustomerView(v as CustomerView)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customerView === 'cart' && (
|
|
||||||
<CartReviewView
|
|
||||||
cart={cart}
|
|
||||||
onUpdateCart={handleUpdateCart}
|
|
||||||
onPlaceOrder={handlePlaceOrder}
|
|
||||||
onNavigate={(v) => setCustomerView(v as CustomerView)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customerView === 'status' && (
|
|
||||||
<OrderStatusView onNavigate={(v) => setCustomerView(v as CustomerView)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customerView === 'bill' && (
|
|
||||||
<BillPaymentView onNavigate={(v) => setCustomerView(v as CustomerView)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Item Customization Dialog */}
|
|
||||||
<CustomizationModal
|
|
||||||
open={!!customizingItem}
|
|
||||||
item={customizingItem}
|
|
||||||
onClose={() => setCustomizingItem(null)}
|
|
||||||
onConfirm={handleCustomizationConfirm}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* AI Voice Assistant Modal (bottom sheet) */}
|
|
||||||
<VoiceAssistantModal
|
|
||||||
open={voiceModalOpen}
|
|
||||||
onClose={() => setVoiceModalOpen(false)}
|
|
||||||
onAddToCart={handleUpdateCart}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Staff Login Modal */}
|
|
||||||
<StaffLoginModal
|
|
||||||
open={staffLoginOpen}
|
|
||||||
onClose={() => setStaffLoginOpen(false)}
|
|
||||||
onLoginSuccess={handleStaffLoginSuccess}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
Button,
|
||||||
Avatar,
|
Avatar,
|
||||||
Divider,
|
Divider,
|
||||||
|
Alert,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
InputLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
|
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
|
||||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||||
|
|
@ -28,6 +33,9 @@ import InventoryIcon from '@mui/icons-material/Inventory';
|
||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
import BadgeIcon from '@mui/icons-material/Badge';
|
import BadgeIcon from '@mui/icons-material/Badge';
|
||||||
import StarHalfIcon from '@mui/icons-material/StarHalf';
|
import StarHalfIcon from '@mui/icons-material/StarHalf';
|
||||||
|
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
|
||||||
|
import WifiOffIcon from '@mui/icons-material/WifiOff';
|
||||||
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
|
|
||||||
import { KDSKanban } from '../kds/KDSKanban';
|
import { KDSKanban } from '../kds/KDSKanban';
|
||||||
import { AnalyticsDashboard } from '../analytics/AnalyticsDashboard';
|
import { AnalyticsDashboard } from '../analytics/AnalyticsDashboard';
|
||||||
|
|
@ -39,21 +47,30 @@ import { InventoryView } from './InventoryView';
|
||||||
import { SuppliersView } from './SuppliersView';
|
import { SuppliersView } from './SuppliersView';
|
||||||
import { StaffShiftsView } from './StaffShiftsView';
|
import { StaffShiftsView } from './StaffShiftsView';
|
||||||
import { FeedbackInboxView } from './FeedbackInboxView';
|
import { FeedbackInboxView } from './FeedbackInboxView';
|
||||||
|
import { MenuManagementView } from './MenuManagementView';
|
||||||
|
import { LowStockNotifier } from './LowStockNotifier';
|
||||||
|
|
||||||
import type { KDSOrder, OrderStatus } from '../../types';
|
import type { KDSOrder, OrderStatus } from '../../types';
|
||||||
|
import type { StaffRole } from './StaffLoginModal';
|
||||||
|
|
||||||
interface AdminDesktopShellProps {
|
interface AdminDesktopShellProps {
|
||||||
role: 'kitchen' | 'manager';
|
role: StaffRole;
|
||||||
orders: KDSOrder[];
|
orders: KDSOrder[];
|
||||||
|
restaurantId: number;
|
||||||
|
restaurants?: { id: number; name: string; is_home?: boolean; is_active?: boolean }[];
|
||||||
|
onSwitchRestaurant?: (restaurantId: number) => void;
|
||||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||||
onToggleItem: (orderId: string, itemId: string) => void;
|
onToggleItem: (orderId: string, itemId: string) => void;
|
||||||
onAddSampleOrder: () => void;
|
onAddSampleOrder: () => void;
|
||||||
onAddOrderFromVoice: (transcript: string) => void;
|
onAddOrderFromVoice: (transcript: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
wsState?: 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||||
|
busyOrderId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType =
|
type TabType =
|
||||||
| 'kds'
|
| 'kds'
|
||||||
|
| 'menu'
|
||||||
| 'tables'
|
| 'tables'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
| 'reservations'
|
| 'reservations'
|
||||||
|
|
@ -64,56 +81,78 @@ type TabType =
|
||||||
| 'analytics'
|
| 'analytics'
|
||||||
| 'voice';
|
| 'voice';
|
||||||
|
|
||||||
|
const defaultTabForRole = (role: StaffRole): TabType => {
|
||||||
|
if (role === 'manager') return 'analytics';
|
||||||
|
if (role === 'cashier') return 'billing';
|
||||||
|
if (role === 'waiter') return 'tables';
|
||||||
|
return 'kds';
|
||||||
|
};
|
||||||
|
|
||||||
export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
||||||
role,
|
role,
|
||||||
orders,
|
orders,
|
||||||
|
restaurantId,
|
||||||
|
restaurants = [],
|
||||||
|
onSwitchRestaurant,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onToggleItem,
|
onToggleItem,
|
||||||
onAddSampleOrder,
|
onAddSampleOrder,
|
||||||
onAddOrderFromVoice,
|
onAddOrderFromVoice,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
wsState = 'idle',
|
||||||
|
busyOrderId = null,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(
|
const [activeTab, setActiveTab] = useState<TabType>(defaultTabForRole(role));
|
||||||
role === 'manager' ? 'analytics' : 'kds'
|
|
||||||
);
|
|
||||||
|
|
||||||
const pendingCount = orders.filter((o) => o.status === 'pending').length;
|
const pendingCount = orders.filter((o) => o.status === 'pending').length;
|
||||||
const drawerWidth = 260;
|
const drawerWidth = 232;
|
||||||
|
|
||||||
// Sidebar list configurations
|
// Sidebar list configurations — matches roadmap §2.4
|
||||||
const menuItems = [
|
const menuItems: { id: TabType; label: string; icon: React.ReactNode; roles: StaffRole[] }[] = [
|
||||||
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon />, roles: ['kitchen', 'manager'] },
|
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
|
||||||
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon />, roles: ['manager'] },
|
{ id: 'menu', label: 'Menu Management', icon: <RestaurantMenuIcon fontSize="small" />, roles: ['manager'] },
|
||||||
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon />, roles: ['manager'] },
|
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon fontSize="small" />, roles: ['waiter', 'manager'] },
|
||||||
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon />, roles: ['manager'] },
|
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon fontSize="small" />, roles: ['cashier', 'waiter', 'manager'] },
|
||||||
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon />, roles: ['kitchen', 'manager'] },
|
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon fontSize="small" />, roles: ['waiter', 'manager'] },
|
||||||
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon />, roles: ['manager'] },
|
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon fontSize="small" />, roles: ['chef', 'manager'] },
|
||||||
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon />, roles: ['manager'] },
|
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon fontSize="small" />, roles: ['manager'] },
|
||||||
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon />, roles: ['manager'] },
|
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon fontSize="small" />, roles: ['manager'] },
|
||||||
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon />, roles: ['manager'] },
|
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon fontSize="small" />, roles: ['manager'] },
|
||||||
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon />, roles: ['kitchen', 'manager'] },
|
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon fontSize="small" />, roles: ['manager'] },
|
||||||
|
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon fontSize="small" />, roles: ['chef', 'waiter', 'manager'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredMenuItems = menuItems.filter((item) => item.roles.includes(role));
|
const filteredMenuItems = menuItems.filter((item) => item.roles.includes(role));
|
||||||
|
|
||||||
const getPageTitle = () => {
|
const getPageTitle = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'kds': return 'Kitchen Display System (KDS) Live Kanban';
|
case 'kds': return 'KDS Live Kanban';
|
||||||
case 'tables': return 'Table Floor & Session Planner';
|
case 'menu': return 'Menu Management';
|
||||||
case 'billing': return 'Cashier Billing Desk & Payments';
|
case 'tables': return 'Tables & Floor';
|
||||||
case 'reservations': return 'Diner Reservations Book';
|
case 'billing': return 'Billing Desk';
|
||||||
case 'inventory': return 'Kitchen Raw Stocks & Recipes Linking';
|
case 'reservations': return 'Reservations';
|
||||||
case 'suppliers': return 'Merchant Suppliers & Purchase Orders';
|
case 'inventory': return 'Kitchen Stock & Recipes';
|
||||||
case 'staff': return 'Employee Attendance & Shift Roster';
|
case 'suppliers': return 'Suppliers & POs';
|
||||||
case 'feedback': return 'Customer Feedback Rating Reviews';
|
case 'staff': return 'Staff & Shifts';
|
||||||
case 'analytics': return 'Executive Business Analytics & Trends';
|
case 'feedback': return 'Customer Feedback';
|
||||||
case 'voice': return 'Staff AI Voice Assistant Terminal';
|
case 'analytics': return 'Business Intelligence';
|
||||||
default: return 'Management Portal';
|
case 'voice': return 'AI Voice Terminal';
|
||||||
|
default: return 'Staff Portal';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleLabel =
|
||||||
|
role === 'manager'
|
||||||
|
? 'Admin Manager'
|
||||||
|
: role === 'cashier'
|
||||||
|
? 'Cashier'
|
||||||
|
: role === 'waiter'
|
||||||
|
? 'Waiter'
|
||||||
|
: 'Chef';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: '#f4f5f7' }}>
|
<Box sx={{ display: 'flex', width: '100%', minHeight: '100vh', bgcolor: '#f4f5f7', textAlign: 'left' }}>
|
||||||
|
<LowStockNotifier enabled={role === 'manager' || role === 'chef'} autoNotify={role === 'manager'} />
|
||||||
{/* Desktop Sidebar Navigation */}
|
{/* Desktop Sidebar Navigation */}
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
|
|
@ -126,84 +165,122 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
||||||
bgcolor: '#1a1c1c',
|
bgcolor: '#1a1c1c',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
borderRight: '1px solid #2f3131',
|
borderRight: '1px solid #2f3131',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ px: 2, py: 2, display: 'flex', alignItems: 'center', gap: 1.25, flexShrink: 0 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LocalDiningIcon />
|
<LocalDiningIcon fontSize="small" />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.1 }}>
|
<Typography sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.15, fontSize: '1.05rem' }}>
|
||||||
RestroAI
|
RestroAI
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.65rem', fontWeight: 700 }}>
|
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.04em' }}>
|
||||||
DESKTOP STAFF PORTAL
|
STAFF PORTAL
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||||
|
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ px: 1.5, py: 1.5, flexShrink: 0 }}>
|
||||||
<Chip
|
<Chip
|
||||||
avatar={<Avatar sx={{ bgcolor: '#ac2d00' }}>{role === 'manager' ? 'M' : 'K'}</Avatar>}
|
avatar={
|
||||||
label={role === 'manager' ? 'Role: Restaurant Manager' : 'Role: Kitchen Head Chef'}
|
<Avatar sx={{ bgcolor: '#ac2d00', width: 24, height: 24, fontSize: '0.75rem' }}>
|
||||||
|
{role === 'manager' ? 'M' : role === 'cashier' ? 'C' : role === 'waiter' ? 'W' : 'K'}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
label={roleLabel}
|
||||||
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'rgba(255,255,255,0.08)',
|
bgcolor: 'rgba(255,255,255,0.08)',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
|
mb: restaurants.length > 1 ? 1.25 : 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{restaurants.length > 1 && onSwitchRestaurant && (
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel id="restaurant-picker-label" sx={{ color: '#c6c6cd' }}>
|
||||||
|
Location
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="restaurant-picker-label"
|
||||||
|
label="Location"
|
||||||
|
value={restaurantId}
|
||||||
|
onChange={(e) => onSwitchRestaurant(Number(e.target.value))}
|
||||||
|
sx={{
|
||||||
|
color: '#fff',
|
||||||
|
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.2)' },
|
||||||
|
'.MuiSvgIcon-root': { color: '#ffb5a0' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restaurants.map((r) => (
|
||||||
|
<MenuItem key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
{r.is_home ? ' (home)' : ''}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<List sx={{ px: 1, overflowY: 'auto', flexGrow: 1 }}>
|
<List sx={{ px: 1, py: 0.5, overflowY: 'auto', flexGrow: 1, minHeight: 0 }}>
|
||||||
{filteredMenuItems.map((item) => (
|
{filteredMenuItems.map((item) => (
|
||||||
<ListItem disablePadding sx={{ mb: 0.5 }} key={item.id}>
|
<ListItem disablePadding sx={{ mb: 0.25 }} key={item.id}>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={activeTab === item.id}
|
selected={activeTab === item.id}
|
||||||
onClick={() => setActiveTab(item.id as TabType)}
|
onClick={() => setActiveTab(item.id as TabType)}
|
||||||
|
dense
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
|
py: 0.75,
|
||||||
'&.Mui-selected': { bgcolor: '#ac2d00', color: '#ffffff' },
|
'&.Mui-selected': { bgcolor: '#ac2d00', color: '#ffffff' },
|
||||||
'&.Mui-selected:hover': { bgcolor: '#872100' },
|
'&.Mui-selected:hover': { bgcolor: '#872100' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 38 }}>
|
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 34 }}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={
|
||||||
<Typography sx={{ fontWeight: activeTab === item.id ? 800 : 500, fontSize: '0.9rem' }}>
|
<Typography sx={{ fontWeight: activeTab === item.id ? 700 : 500, fontSize: '0.8125rem', lineHeight: 1.3 }}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{item.id === 'kds' && pendingCount > 0 && (
|
{item.id === 'kds' && pendingCount > 0 && (
|
||||||
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20 }} />
|
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20, ml: 0.5 }} />
|
||||||
)}
|
)}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Box sx={{ mt: 'auto', p: 2 }}>
|
<Box sx={{ p: 1.5, flexShrink: 0, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
startIcon={<LogoutIcon />}
|
startIcon={<LogoutIcon />}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -217,46 +294,97 @@ export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Switch to Diner Mode
|
Log out
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<Box component="main" sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
<Box
|
||||||
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2' }}>
|
component="main"
|
||||||
<Toolbar sx={{ justifyContent: 'space-between' }}>
|
sx={{
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(wsState === 'reconnecting' || wsState === 'connecting') && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={<WifiOffIcon />}
|
||||||
|
sx={{ borderRadius: 0, fontWeight: 700, py: 0.5 }}
|
||||||
|
>
|
||||||
|
Reconnecting to kitchen… board will refresh when live.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2', flexShrink: 0 }}>
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
minHeight: { xs: 56, sm: 60 },
|
||||||
|
px: { xs: 2, md: 3 },
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#ac2d00',
|
||||||
|
fontSize: { xs: '1rem', md: '1.15rem' },
|
||||||
|
lineHeight: 1.25,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: '1 1 auto',
|
||||||
|
textAlign: 'left',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{getPageTitle()}
|
{getPageTitle()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5 }, flexShrink: 0 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label="System Status: LIVE ONLINE"
|
icon={wsState === 'open' ? <WifiIcon /> : <WifiOffIcon />}
|
||||||
color="success"
|
label={
|
||||||
|
wsState === 'open'
|
||||||
|
? 'Kitchen live'
|
||||||
|
: wsState === 'reconnecting' || wsState === 'connecting'
|
||||||
|
? 'Reconnecting…'
|
||||||
|
: 'WS idle'
|
||||||
|
}
|
||||||
|
color={wsState === 'open' ? 'success' : 'warning'}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ fontWeight: 800 }}
|
sx={{ fontWeight: 800 }}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<AccountCircleIcon sx={{ color: '#ac2d00' }} />
|
<AccountCircleIcon sx={{ color: '#ac2d00', fontSize: 22 }} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, whiteSpace: 'nowrap', display: { xs: 'none', sm: 'block' } }}>
|
||||||
Logged in as {role === 'manager' ? 'Admin Manager' : 'Chef Rahul S.'}
|
{roleLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box sx={{ p: 3, flexGrow: 1, overflowY: 'auto' }}>
|
<Box sx={{ px: { xs: 1.5, md: 2.5 }, py: { xs: 1.5, md: 2 }, flexGrow: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
{activeTab === 'kds' && (
|
{activeTab === 'kds' && (
|
||||||
<KDSKanban
|
<KDSKanban
|
||||||
orders={orders}
|
orders={orders}
|
||||||
|
busyOrderId={busyOrderId}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
onToggleItem={onToggleItem}
|
onToggleItem={onToggleItem}
|
||||||
onAddSampleOrder={onAddSampleOrder}
|
onAddSampleOrder={onAddSampleOrder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'menu' && <MenuManagementView />}
|
||||||
|
|
||||||
{activeTab === 'tables' && <TableManagementView />}
|
{activeTab === 'tables' && <TableManagementView />}
|
||||||
|
|
||||||
{activeTab === 'billing' && <BillingManagerView />}
|
{activeTab === 'billing' && <BillingManagerView />}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Button,
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
|
Chip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
|
@ -133,9 +134,30 @@ export const FeedbackInboxView: React.FC = () => {
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
secondary={
|
secondary={
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
|
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
|
||||||
{fb.comment_text || '(No comment left by guest)'}
|
{fb.comment_text || '(No comment left by guest)'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{(fb.sentiment_results || []).length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
|
||||||
|
{fb.sentiment_results.map((row: any) => (
|
||||||
|
<Chip
|
||||||
|
key={`${fb.id}-${row.dimension}-${row.id || row.sentiment}`}
|
||||||
|
size="small"
|
||||||
|
label={`${row.dimension}: ${row.sentiment}`}
|
||||||
|
color={
|
||||||
|
row.sentiment === 'positive'
|
||||||
|
? 'success'
|
||||||
|
: row.sentiment === 'negative'
|
||||||
|
? 'error'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
sx={{ fontWeight: 700, textTransform: 'capitalize' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,11 @@ import AddIcon from '@mui/icons-material/Add';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
import { MENU_ITEMS } from '../../data/menuData';
|
import type { StaffMenuItem } from '../../services/api';
|
||||||
|
|
||||||
export const InventoryView: React.FC = () => {
|
export const InventoryView: React.FC = () => {
|
||||||
const [ingredients, setIngredients] = useState<any[]>([]);
|
const [ingredients, setIngredients] = useState<any[]>([]);
|
||||||
|
const [menuDishes, setMenuDishes] = useState<StaffMenuItem[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -65,8 +66,16 @@ export const InventoryView: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getIngredients();
|
const [data, dishes] = await Promise.all([
|
||||||
|
api.getIngredients(),
|
||||||
|
api.listMenuItems().catch(() => []),
|
||||||
|
]);
|
||||||
setIngredients(Array.isArray(data) ? data : []);
|
setIngredients(Array.isArray(data) ? data : []);
|
||||||
|
const dishList = Array.isArray(dishes) ? dishes : [];
|
||||||
|
setMenuDishes(dishList);
|
||||||
|
if (dishList.length && !dishList.some((d) => d.id === selectedMenuId)) {
|
||||||
|
setSelectedMenuId(dishList[0].id);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch ingredients');
|
setError(err.message || 'Failed to fetch ingredients');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -271,9 +280,9 @@ export const InventoryView: React.FC = () => {
|
||||||
label="Select Menu Dish"
|
label="Select Menu Dish"
|
||||||
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
|
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => (
|
{menuDishes.map((item) => (
|
||||||
<MenuItem key={item.id} value={parseInt(item.id.replace('m', ''))}>
|
<MenuItem key={item.id} value={item.id}>
|
||||||
{item.name} (₹{item.price})
|
{item.name} (₹{Number(item.price)})
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import BadgeIcon from '@mui/icons-material/Badge';
|
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
|
export type StaffRole = 'chef' | 'waiter' | 'cashier' | 'manager';
|
||||||
|
|
||||||
interface StaffLoginModalProps {
|
interface StaffLoginModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onLoginSuccess: (role: 'kitchen' | 'manager') => void;
|
onLoginSuccess: (role: StaffRole) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeRole = (role: string): StaffRole => {
|
||||||
|
const value = role.trim().toLowerCase();
|
||||||
|
if (value === 'manager') return 'manager';
|
||||||
|
if (value === 'waiter') return 'waiter';
|
||||||
|
if (value === 'cashier') return 'cashier';
|
||||||
|
return 'chef';
|
||||||
|
};
|
||||||
|
|
||||||
export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -43,10 +52,8 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.login(username, password);
|
await api.login(username, password);
|
||||||
// Determine role based on JWT claims or username
|
const me = await api.getMe();
|
||||||
const role = username.includes('manager') ? 'manager' : 'kitchen';
|
onLoginSuccess(normalizeRole(me.role));
|
||||||
onLoginSuccess(role);
|
|
||||||
onClose();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Login failed. Please check your credentials.');
|
setError(err.message || 'Login failed. Please check your credentials.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -81,7 +88,7 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
||||||
Staff & Admin Portal Login
|
Staff & Admin Portal Login
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#5b4139' }}>
|
<Typography variant="caption" sx={{ color: '#5b4139' }}>
|
||||||
Authorized Restaurant Personnel Only
|
Role comes from JWT via /auth/me
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
|
|
@ -97,70 +104,62 @@ export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
|
||||||
QUICK AUTOFILL DEMO LOGIN:
|
QUICK AUTOFILL DEMO LOGIN:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<BadgeIcon fontSize="small" />}
|
label="Manager"
|
||||||
label="Manager Account"
|
|
||||||
clickable
|
clickable
|
||||||
onClick={() => fillCredentials('manager@test.com', 'managerpass')}
|
onClick={() => fillCredentials('manager@demo.restro', 'Manager@12345')}
|
||||||
sx={{ fontWeight: 700, flex: 1 }}
|
sx={{ fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<BadgeIcon fontSize="small" />}
|
label="Chef"
|
||||||
label="Chef / Waiter Account"
|
|
||||||
clickable
|
clickable
|
||||||
onClick={() => fillCredentials('waiter@test.com', 'waiterpass')}
|
onClick={() => fillCredentials('chef@demo.restro', 'Chef@12345')}
|
||||||
sx={{ fontWeight: 700, flex: 1 }}
|
sx={{ fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Waiter"
|
||||||
|
clickable
|
||||||
|
onClick={() => fillCredentials('waiter@demo.restro', 'Waiter@12345')}
|
||||||
|
sx={{ fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Cashier"
|
||||||
|
clickable
|
||||||
|
onClick={() => fillCredentials('cashier@demo.restro', 'Cashier@12345')}
|
||||||
|
sx={{ fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Email Address / Username"
|
label="Email"
|
||||||
type="email"
|
|
||||||
placeholder="manager@test.com"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
sx={{ mb: 2 }}
|
autoComplete="username"
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
sx={{ mb: 2.5 }}
|
autoComplete="current-password"
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<DialogActions sx={{ px: 0, pt: 2 }}>
|
||||||
type="submit"
|
<Button onClick={onClose} disabled={loading}>
|
||||||
fullWidth
|
Cancel
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
disabled={loading}
|
|
||||||
sx={{
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: '9999px',
|
|
||||||
fontWeight: 800,
|
|
||||||
bgcolor: '#ac2d00',
|
|
||||||
'&:hover': { bgcolor: '#872100' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Access Portal'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
<Button type="submit" variant="contained" disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>
|
||||||
</DialogContent>
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
|
|
||||||
<DialogActions sx={{ p: 2, justifyContent: 'center' }}>
|
|
||||||
<Button size="small" onClick={onClose} color="inherit" disabled={loading}>
|
|
||||||
Cancel & Return to Dining
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const StaffShiftsView: React.FC = () => {
|
||||||
const [staffName, setStaffName] = useState<string>('');
|
const [staffName, setStaffName] = useState<string>('');
|
||||||
const [staffEmail, setStaffEmail] = useState<string>('');
|
const [staffEmail, setStaffEmail] = useState<string>('');
|
||||||
const [staffPassword, setStaffPassword] = useState<string>('');
|
const [staffPassword, setStaffPassword] = useState<string>('');
|
||||||
const [staffRoleId, setStaffRoleId] = useState<number>(2); // default waiter role_id
|
const [staffRoleId] = useState<number>(2); // default waiter role_id
|
||||||
const [staffRole, setStaffRole] = useState<string>('waiter'); // display only
|
const [staffRole, setStaffRole] = useState<string>('waiter'); // display only
|
||||||
const [roles, setRoles] = useState<any[]>([]); // RoleRead[] from backend
|
const [roles, setRoles] = useState<any[]>([]); // RoleRead[] from backend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,16 @@ export const SuppliersView: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Backend schema: { ingredient_id, quantity, unit_price }
|
// Backend schema: { ingredient_id, quantity, unit_price }
|
||||||
setPoItems(prev => [...prev, { ingredient_id: Number(addIngId), quantity: addIngQty, unit_price: addIngPrice }]);
|
setPoItems(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
ingredient_id: Number(addIngId),
|
||||||
|
quantity: addIngQty,
|
||||||
|
unit_price: addIngPrice,
|
||||||
|
qty: addIngQty,
|
||||||
|
price: addIngPrice,
|
||||||
|
},
|
||||||
|
]);
|
||||||
setAddIngId('');
|
setAddIngId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,2 @@
|
||||||
import React from 'react';
|
/** Re-export shared badges from @restroai/ui. */
|
||||||
import { Chip, Box, Typography } from '@mui/material';
|
export { StatusBadge, DietaryBadge } from '@restroai/ui';
|
||||||
import type { OrderStatus, OrderType } from '../../types';
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status?: OrderStatus;
|
|
||||||
type?: OrderType;
|
|
||||||
dietary?: 'veg' | 'non-veg' | 'jain' | 'vegan';
|
|
||||||
size?: 'small' | 'medium';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type, dietary, size = 'small' }) => {
|
|
||||||
if (dietary) {
|
|
||||||
const config = {
|
|
||||||
veg: { label: 'VEG', color: '#2e7d32', bg: '#e8f5e9', border: '#2e7d32' },
|
|
||||||
'non-veg': { label: 'NON-VEG', color: '#c62828', bg: '#ffebee', border: '#c62828' },
|
|
||||||
jain: { label: 'JAIN', color: '#ef6c00', bg: '#fff3e0', border: '#ef6c00' },
|
|
||||||
vegan: { label: 'VEGAN', color: '#1565c0', bg: '#e3f2fd', border: '#1565c0' },
|
|
||||||
}[dietary];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
border: `1px solid ${config.border}`,
|
|
||||||
borderRadius: '4px',
|
|
||||||
px: 0.6,
|
|
||||||
py: 0.2,
|
|
||||||
backgroundColor: config.bg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: config.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: config.color,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
const statusMap = {
|
|
||||||
pending: { label: 'PENDING', color: 'error', bg: '#ffdad6' },
|
|
||||||
preparing: { label: 'PREPARING', color: 'warning', bg: '#ffddba' },
|
|
||||||
ready: { label: 'READY', color: 'success', bg: '#a3f69c' },
|
|
||||||
served: { label: 'SERVED', color: 'default', bg: '#eeeeee' },
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={statusMap.label}
|
|
||||||
size={size}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: size === 'small' ? 22 : 28,
|
|
||||||
}}
|
|
||||||
color={statusMap.color as any}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type) {
|
|
||||||
const typeMap = {
|
|
||||||
'Dine-In': { label: 'DINE-IN', color: '#ac2d00', bg: '#ffdbd1' },
|
|
||||||
Takeaway: { label: 'TAKEAWAY', color: '#546067', bg: '#d7e4ec' },
|
|
||||||
Delivery: { label: 'DELIVERY', color: '#845000', bg: '#ffddba' },
|
|
||||||
}[type];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={typeMap.label}
|
|
||||||
size={size}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
height: size === 'small' ? 22 : 28,
|
|
||||||
backgroundColor: typeMap.bg,
|
|
||||||
color: typeMap.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
|
|
@ -9,81 +9,239 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
Alert,
|
Alert,
|
||||||
Grid,
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import ErrorOutlineIcon from '@mui/icons-material/WarningAmber';
|
||||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import CreditCardIcon from '@mui/icons-material/CreditCard';
|
import CreditCardIcon from '@mui/icons-material/CreditCard';
|
||||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||||
import { api } from '../../services/api';
|
import { api, getFrontendMenuItem } from '../../services/api';
|
||||||
|
|
||||||
interface BillPaymentViewProps {
|
interface BillPaymentViewProps {
|
||||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PayOutcome = 'idle' | 'success' | 'failure';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Razorpay?: new (options: Record<string, unknown>) => { open: () => void };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRazorpayScript(): Promise<void> {
|
||||||
|
if (window.Razorpay) return;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const existing = document.querySelector('script[data-razorpay="1"]');
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve());
|
||||||
|
existing.addEventListener('error', () => reject(new Error('Razorpay script failed')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
|
||||||
|
script.async = true;
|
||||||
|
script.dataset.razorpay = '1';
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Failed to load Razorpay checkout'));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
|
export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
|
||||||
const [splitCount, setSplitCount] = useState<number>(1);
|
const [splitCount, setSplitCount] = useState<number>(1);
|
||||||
const [tipPercentage, setTipPercentage] = useState<number>(10);
|
const [tipPercentage, setTipPercentage] = useState<number>(10);
|
||||||
const [paymentDone, setPaymentDone] = useState<boolean>(false);
|
const [outcome, setOutcome] = useState<PayOutcome>('idle');
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'upi' | 'card' | 'cash' | null>(null);
|
const [paymentMethod, setPaymentMethod] = useState<'upi' | 'card' | 'cash' | null>(null);
|
||||||
const [billItems, setBillItems] = useState<any[]>([]);
|
const [billItems, setBillItems] = useState<
|
||||||
|
{ name: string; qty: number; price: number }[]
|
||||||
|
>([]);
|
||||||
const [ticketNum, setTicketNum] = useState<string>('---');
|
const [ticketNum, setTicketNum] = useState<string>('---');
|
||||||
const [tableNum, setTableNum] = useState<string>('12');
|
const [tableNum, setTableNum] = useState<string>('12');
|
||||||
const [billId, setBillId] = useState<string>('');
|
const [billId, setBillId] = useState<number | null>(null);
|
||||||
|
const [billTotal, setBillTotal] = useState<number>(0);
|
||||||
|
const [billSubtotal, setBillSubtotal] = useState<number>(0);
|
||||||
|
const [cgstAmount, setCgstAmount] = useState<number>(0);
|
||||||
|
const [sgstAmount, setSgstAmount] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [paying, setPaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cashHint, setCashHint] = useState(false);
|
||||||
|
|
||||||
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
|
const sessionId = sessionStorage.getItem('customer_session_id') || '';
|
||||||
|
|
||||||
const loadBill = async () => {
|
const markSuccess = useCallback((method: 'upi' | 'card' | 'cash') => {
|
||||||
|
setPaymentMethod(method);
|
||||||
|
setOutcome('success');
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('pay', 'success');
|
||||||
|
if (billId) url.searchParams.set('bill_id', String(billId));
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}, [billId]);
|
||||||
|
|
||||||
|
const markFailure = useCallback((message?: string) => {
|
||||||
|
setOutcome('failure');
|
||||||
|
if (message) setError(message);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('pay', 'failure');
|
||||||
|
if (billId) url.searchParams.set('bill_id', String(billId));
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}, [billId]);
|
||||||
|
|
||||||
|
const loadBill = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const payParam = new URLSearchParams(window.location.search).get('pay');
|
||||||
|
if (payParam === 'success') setOutcome('success');
|
||||||
|
if (payParam === 'failure') setOutcome('failure');
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
setError('No active table session. Scan the QR code to start.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const order = await api.getOrderForSession(sessionId);
|
const order = await api.getOrderForSession(sessionId);
|
||||||
if (order) {
|
if (!order) {
|
||||||
setTicketNum(order.ticketNumber || '105');
|
setError('No order found for this table yet.');
|
||||||
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBillItems(order.items.map((it: any) => ({
|
setTicketNum(String(order.id ?? '---'));
|
||||||
name: it.name,
|
setTableNum(sessionStorage.getItem('customer_table_id') || '12');
|
||||||
qty: it.quantity,
|
|
||||||
price: it.price || 150
|
|
||||||
})));
|
|
||||||
|
|
||||||
const bill = await api.generateBill(order.id);
|
const items = (order.items || []).map((it: any) => {
|
||||||
if (bill) {
|
const menu = getFrontendMenuItem?.(it.menu_item_id);
|
||||||
setBillId(bill.id);
|
return {
|
||||||
}
|
name: it.name || menu?.name || `Dish ${it.menu_item_id}`,
|
||||||
}
|
qty: Number(it.quantity) || 1,
|
||||||
} catch (e) {
|
price: Number(it.unit_price ?? it.price ?? menu?.price ?? 0),
|
||||||
console.error('Failed to load bill', e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
setBillItems(items);
|
||||||
|
|
||||||
|
const bill = await api.sessionGenerateBill(order.id);
|
||||||
|
if (bill) {
|
||||||
|
setBillId(Number(bill.id));
|
||||||
|
setBillSubtotal(Number(bill.subtotal));
|
||||||
|
setCgstAmount(Number(bill.cgst_amount));
|
||||||
|
setSgstAmount(Number(bill.sgst_amount));
|
||||||
|
setBillTotal(Number(bill.total_amount));
|
||||||
|
if (bill.status === 'paid') {
|
||||||
|
setOutcome('success');
|
||||||
|
setPaymentMethod('upi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load bill', e);
|
||||||
|
setError(e?.message || 'Failed to load bill');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBill();
|
loadBill();
|
||||||
}, []);
|
}, [loadBill]);
|
||||||
|
|
||||||
const subtotal = billItems.reduce((acc, i) => acc + i.price * i.qty, 0);
|
const tip = billSubtotal * (tipPercentage / 100);
|
||||||
const cgst = subtotal * 0.025;
|
const displayTotal = billTotal + tip;
|
||||||
const sgst = subtotal * 0.025;
|
const perPersonPrice = displayTotal / splitCount;
|
||||||
const serviceCharge = subtotal * 0.05;
|
|
||||||
const tip = subtotal * (tipPercentage / 100);
|
|
||||||
const totalBill = subtotal + cgst + sgst + serviceCharge + tip;
|
|
||||||
const perPersonPrice = totalBill / splitCount;
|
|
||||||
|
|
||||||
const handlePay = async (method: 'upi' | 'card' | 'cash') => {
|
const confirmMockCheckout = async (
|
||||||
try {
|
checkout: any,
|
||||||
if (billId) {
|
method: 'upi' | 'card'
|
||||||
await api.recordPayment(billId, method, totalBill);
|
) => {
|
||||||
|
await api.sessionConfirmRazorpay(checkout.bill_id, {
|
||||||
|
razorpay_order_id: checkout.razorpay_order_id,
|
||||||
|
razorpay_payment_id: `pay_mock_${Date.now()}`,
|
||||||
|
razorpay_signature: 'mock',
|
||||||
|
amount: Number(checkout.amount),
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
markSuccess(method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLiveCheckout = async (checkout: any, method: 'upi' | 'card') => {
|
||||||
|
await loadRazorpayScript();
|
||||||
|
if (!window.Razorpay) {
|
||||||
|
throw new Error('Razorpay checkout unavailable');
|
||||||
}
|
}
|
||||||
setPaymentMethod(method);
|
const amountPaise = Math.round(Number(checkout.amount) * 100);
|
||||||
setPaymentDone(true);
|
const rzp = new window.Razorpay({
|
||||||
} catch (e) {
|
key: checkout.key_id,
|
||||||
alert('Failed to record payment: ' + e);
|
amount: amountPaise,
|
||||||
|
currency: checkout.currency || 'INR',
|
||||||
|
name: 'RestroAI',
|
||||||
|
description: `Bill #${checkout.bill_id}`,
|
||||||
|
order_id: checkout.razorpay_order_id,
|
||||||
|
handler: async (response: {
|
||||||
|
razorpay_order_id: string;
|
||||||
|
razorpay_payment_id: string;
|
||||||
|
razorpay_signature: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
await api.sessionConfirmRazorpay(checkout.bill_id, {
|
||||||
|
razorpay_order_id: response.razorpay_order_id,
|
||||||
|
razorpay_payment_id: response.razorpay_payment_id,
|
||||||
|
razorpay_signature: response.razorpay_signature,
|
||||||
|
amount: Number(checkout.amount),
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
markSuccess(method);
|
||||||
|
} catch (err: any) {
|
||||||
|
markFailure(err?.message || 'Payment confirmation failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
ondismiss: () => {
|
||||||
|
markFailure('Payment cancelled');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: { color: '#ac2d00' },
|
||||||
|
});
|
||||||
|
rzp.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGatewayPay = async (method: 'upi' | 'card') => {
|
||||||
|
if (!billId) {
|
||||||
|
setError('Bill not ready yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPaying(true);
|
||||||
|
setError(null);
|
||||||
|
setCashHint(false);
|
||||||
|
try {
|
||||||
|
const checkout = await api.sessionCheckoutRazorpay(billId);
|
||||||
|
if (checkout.mock) {
|
||||||
|
await confirmMockCheckout(checkout, method);
|
||||||
|
} else {
|
||||||
|
await openLiveCheckout(checkout, method);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
markFailure(e?.message || 'Checkout failed');
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCash = () => {
|
||||||
|
setCashHint(true);
|
||||||
|
setPaymentMethod('cash');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '60vh', display: 'grid', placeItems: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
|
||||||
{/* Header */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
|
|
@ -104,19 +262,26 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
Bill & Payment
|
Bill & Payment
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||||
🪑 Table {tableNum} · Ticket #{ticketNum}
|
Table {tableNum} · Order #{ticketNum}
|
||||||
|
{billId ? ` · Bill #${billId}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<ReceiptLongIcon fontSize="small" />}
|
icon={<ReceiptLongIcon fontSize="small" />}
|
||||||
label={paymentDone ? 'PAID ✓' : 'UNPAID'}
|
label={outcome === 'success' ? 'PAID ✓' : outcome === 'failure' ? 'FAILED' : 'UNPAID'}
|
||||||
color={paymentDone ? 'success' : 'error'}
|
color={outcome === 'success' ? 'success' : outcome === 'failure' ? 'warning' : 'error'}
|
||||||
sx={{ fontWeight: 800 }}
|
sx={{ fontWeight: 800 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Container maxWidth="sm" sx={{ pt: 3 }}>
|
<Container maxWidth="sm" sx={{ pt: 3 }}>
|
||||||
{paymentDone ? (
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, borderRadius: '12px' }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outcome === 'success' ? (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -132,10 +297,13 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
Payment Successful!
|
Payment Successful!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}>
|
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}>
|
||||||
₹{totalBill.toFixed(0)} paid via {paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}
|
₹{billTotal.toFixed(0)} paid
|
||||||
|
{paymentMethod
|
||||||
|
? ` via ${paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}`
|
||||||
|
: ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}>
|
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}>
|
||||||
Thank you for dining with RestroAI! Your receipt has been sent.
|
Thank you for dining with RestroAI!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|
@ -147,16 +315,61 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
Back to Home
|
Back to Home
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
) : outcome === 'failure' ? (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: '24px',
|
||||||
|
border: '2px solid #ffccbc',
|
||||||
|
bgcolor: '#fff8f6',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorOutlineIcon sx={{ fontSize: 64, color: '#ac2d00', mb: 2 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00', mb: 1 }}>
|
||||||
|
Payment not completed
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 3 }}>
|
||||||
|
You can try again or ask staff for help.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
setOutcome('idle');
|
||||||
|
setError(null);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('pay');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}}
|
||||||
|
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800, bgcolor: '#ac2d00' }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Itemized Bill */}
|
<Paper
|
||||||
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
elevation={0}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid #f0ebe7',
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
||||||
🧾 Bill Details
|
Bill Details
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068', fontFamily: 'monospace', fontWeight: 700 }}>
|
|
||||||
GSTIN: 29XXXXXX0001Z5
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -174,32 +387,48 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
<Divider sx={{ my: 1.5 }} />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ label: 'Subtotal', value: `₹${subtotal.toFixed(0)}` },
|
{ label: 'Subtotal', value: `₹${billSubtotal.toFixed(0)}` },
|
||||||
{ label: 'CGST (2.5%)', value: `₹${cgst.toFixed(0)}` },
|
{ label: 'CGST', value: `₹${cgstAmount.toFixed(0)}` },
|
||||||
{ label: 'SGST (2.5%)', value: `₹${sgst.toFixed(0)}` },
|
{ label: 'SGST', value: `₹${sgstAmount.toFixed(0)}` },
|
||||||
{ label: 'Service Charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
{ label: `Suggested tip (${tipPercentage}%)`, value: `₹${tip.toFixed(0)}` },
|
||||||
{ label: `Staff Tip (${tipPercentage}%)`, value: `₹${tip.toFixed(0)}` },
|
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
|
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
|
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
|
{row.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{row.value}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Total Amount</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
|
||||||
|
Pay now
|
||||||
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
<Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||||
₹{totalBill.toFixed(0)}
|
₹{billTotal.toFixed(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||||
|
Gateway charges bill total (tip is optional / cash).
|
||||||
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Split Bill */}
|
<Paper
|
||||||
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid #f0ebe7',
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||||
👥 Split the Bill
|
Split the Bill
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||||
{[1, 2, 3, 4].map((count) => (
|
{[1, 2, 3, 4].map((count) => (
|
||||||
|
|
@ -217,15 +446,23 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
</Box>
|
</Box>
|
||||||
{splitCount > 1 && (
|
{splitCount > 1 && (
|
||||||
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
|
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
|
||||||
Each person pays: ₹{perPersonPrice.toFixed(0)}
|
Each person ≈ ₹{perPersonPrice.toFixed(0)} (incl. suggested tip)
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Tip Selector */}
|
<Paper
|
||||||
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid #f0ebe7',
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||||
❤️ Add Tip for Staff
|
Suggested tip
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
{[0, 10, 15, 20].map((pct) => (
|
{[0, 10, 15, 20].map((pct) => (
|
||||||
|
|
@ -242,19 +479,33 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Payment Options */}
|
{cashHint && (
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#8f7068', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
<Alert severity="info" sx={{ mb: 2, borderRadius: '12px', fontWeight: 700 }}>
|
||||||
|
Please pay cash to your waiter. They will mark the bill paid at the counter.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#8f7068',
|
||||||
|
mb: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Choose Payment Method
|
Choose Payment Method
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{/* UPI Button */}
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => handlePay('upi')}
|
disabled={paying || !billId}
|
||||||
startIcon={<QrCode2Icon />}
|
onClick={() => handleGatewayPay('upi')}
|
||||||
|
startIcon={paying ? <CircularProgress size={18} color="inherit" /> : <QrCode2Icon />}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.8,
|
py: 1.8,
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
|
|
@ -264,7 +515,7 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
'&:hover': { bgcolor: '#38006b' },
|
'&:hover': { bgcolor: '#38006b' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pay ₹{totalBill.toFixed(0)} via UPI / GPay / PhonePe
|
Pay ₹{billTotal.toFixed(0)} via UPI / GPay / PhonePe
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Grid container spacing={1.5}>
|
<Grid container spacing={1.5}>
|
||||||
|
|
@ -273,9 +524,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => handlePay('card')}
|
disabled={paying || !billId}
|
||||||
|
onClick={() => handleGatewayPay('card')}
|
||||||
startIcon={<CreditCardIcon />}
|
startIcon={<CreditCardIcon />}
|
||||||
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#ac2d00',
|
||||||
|
borderColor: '#ac2d00',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Debit / Credit Card
|
Debit / Credit Card
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -285,9 +543,16 @@ export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate })
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => handlePay('cash')}
|
disabled={paying}
|
||||||
|
onClick={handleCash}
|
||||||
startIcon={<CurrencyRupeeIcon />}
|
startIcon={<CurrencyRupeeIcon />}
|
||||||
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#2e7d32', borderColor: '#2e7d32' }}
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#2e7d32',
|
||||||
|
borderColor: '#2e7d32',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Pay by Cash
|
Pay by Cash
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
Chip,
|
||||||
Card,
|
Card,
|
||||||
CardMedia,
|
CardMedia,
|
||||||
|
BottomNavigation,
|
||||||
|
BottomNavigationAction,
|
||||||
|
Badge,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
|
@ -18,41 +21,65 @@ import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||||
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
|
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
|
||||||
import { MENU_ITEMS } from '../../data/menuData';
|
import { MENU_ITEMS } from '../../data/menuData';
|
||||||
import type { MenuItem } from '../../data/menuData';
|
import type { MenuItem } from '../../data/menuData';
|
||||||
import { StatusBadge } from '../common/StatusBadge';
|
import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
|
||||||
interface CartReviewViewProps {
|
interface CartReviewViewProps {
|
||||||
cart: { [itemId: string]: number };
|
cart: { [itemId: string]: number };
|
||||||
|
cartNotes?: { [itemId: string]: string };
|
||||||
|
menuItems?: MenuItem[];
|
||||||
onUpdateCart: (itemId: string, quantity: number) => void;
|
onUpdateCart: (itemId: string, quantity: number) => void;
|
||||||
onPlaceOrder: (orderNotes: string) => void;
|
onPlaceOrder: (orderNotes: string) => void;
|
||||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableLabel = () =>
|
||||||
|
sessionStorage.getItem('customer_table_number') ||
|
||||||
|
sessionStorage.getItem('customer_table_id') ||
|
||||||
|
'—';
|
||||||
|
|
||||||
export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||||
cart,
|
cart,
|
||||||
|
cartNotes = {},
|
||||||
|
menuItems = MENU_ITEMS,
|
||||||
onUpdateCart,
|
onUpdateCart,
|
||||||
onPlaceOrder,
|
onPlaceOrder,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}) => {
|
}) => {
|
||||||
const [orderNotes, setOrderNotes] = useState<string>('');
|
const [orderNotes, setOrderNotes] = useState<string>('');
|
||||||
|
const [placing, setPlacing] = useState(false);
|
||||||
|
|
||||||
const cartEntries = Object.entries(cart)
|
const cartEntries = Object.entries(cart)
|
||||||
.map(([id, qty]) => {
|
.map(([id, qty]) => {
|
||||||
const item = MENU_ITEMS.find((m) => m.id === id);
|
const item = menuItems.find((m) => String(m.id) === id);
|
||||||
return item ? { item, qty } : null;
|
return item ? { item, qty, notes: cartNotes[id] } : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean) as { item: MenuItem; qty: number }[];
|
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[];
|
||||||
|
|
||||||
|
const totalUnits = cartEntries.reduce((sum, e) => sum + e.qty, 0);
|
||||||
const subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
|
const subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
|
||||||
const cgst = subtotal * 0.025; // CGST 2.5%
|
const cgst = subtotal * 0.025;
|
||||||
const sgst = subtotal * 0.025; // SGST 2.5%
|
const sgst = subtotal * 0.025;
|
||||||
const serviceCharge = subtotal * 0.05; // 5% service charge
|
const serviceCharge = subtotal * 0.05;
|
||||||
const grandTotal = subtotal + cgst + sgst + serviceCharge;
|
const grandTotal = subtotal + cgst + sgst + serviceCharge;
|
||||||
|
|
||||||
|
const handlePlace = async () => {
|
||||||
|
if (placing || cartEntries.length === 0) return;
|
||||||
|
setPlacing(true);
|
||||||
|
try {
|
||||||
|
await Promise.resolve(onPlaceOrder(orderNotes));
|
||||||
|
} finally {
|
||||||
|
setPlacing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: cartEntries.length ? 22 : 12 }}>
|
||||||
{/* Header */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
|
|
@ -68,22 +95,22 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||||
<IconButton onClick={() => onNavigate('menu')} size="small">
|
<IconButton onClick={() => onNavigate('menu')} size="small" aria-label="Back to menu">
|
||||||
<ArrowBackIcon sx={{ color: '#ac2d00' }} />
|
<ArrowBackIcon sx={{ color: '#ac2d00' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.15 }}>
|
||||||
Your Cart
|
Review order
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||||
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'}
|
Table {tableLabel()} · Check items before placing
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
label={`${cartEntries.length} item${cartEntries.length !== 1 ? 's' : ''}`}
|
label={`${totalUnits} item${totalUnits === 1 ? '' : 's'}`}
|
||||||
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00' }}
|
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -93,22 +120,29 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }}
|
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }}
|
||||||
>
|
>
|
||||||
|
<Typography sx={{ fontSize: '2.5rem', mb: 1 }}>🛒</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
|
||||||
Your cart is empty
|
Your cart is empty
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}>
|
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}>
|
||||||
Go back and add some delicious dishes!
|
Browse the menu and add dishes. You can check your cart anytime while ordering.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" color="primary" onClick={() => onNavigate('menu')}
|
<Button
|
||||||
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}>
|
variant="contained"
|
||||||
Browse Menu
|
color="primary"
|
||||||
|
onClick={() => onNavigate('menu')}
|
||||||
|
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
|
||||||
|
>
|
||||||
|
Browse menu
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Cart Items */}
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, px: 0.5 }}>
|
||||||
|
Items in your cart
|
||||||
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
|
||||||
{cartEntries.map(({ item, qty }) => (
|
{cartEntries.map(({ item, qty, notes }) => (
|
||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
|
|
@ -129,43 +163,84 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusBadge dietary={item.dietary} />
|
<StatusBadge dietary={item.dietary} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', my: 0.5 }}>
|
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
||||||
₹{(item.price * qty).toFixed(0)}
|
₹{item.price} each
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
{notes ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
color: '#845000',
|
||||||
|
bgcolor: '#fff8ee',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.35,
|
||||||
|
mt: 0.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#f8f5f2', borderRadius: '9999px' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#f8f5f2', borderRadius: '9999px' }}>
|
||||||
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty - 1)}>
|
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty - 1)}>
|
||||||
<RemoveIcon sx={{ fontSize: 16 }} />
|
<RemoveIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="body2" sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00' }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00', minWidth: 20, textAlign: 'center' }}
|
||||||
|
>
|
||||||
{qty}
|
{qty}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty + 1)}>
|
<IconButton size="small" onClick={() => onUpdateCart(String(item.id), qty + 1)}>
|
||||||
<AddIcon sx={{ fontSize: 16 }} />
|
<AddIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small" color="error" onClick={() => onUpdateCart(item.id, 0)}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontWeight: 900, color: '#ac2d00' }}>₹{(item.price * qty).toFixed(0)}</Typography>
|
||||||
|
<IconButton size="small" color="error" onClick={() => onUpdateCart(String(item.id), 0)} aria-label="Remove">
|
||||||
<DeleteIcon sx={{ fontSize: 18 }} />
|
<DeleteIcon sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Instructions */}
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onNavigate('menu')}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 800,
|
||||||
|
py: 1.25,
|
||||||
|
color: '#ac2d00',
|
||||||
|
borderColor: '#e4ddd8',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
'&:hover': { borderColor: '#ac2d00', bgcolor: '#fff8f5' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add more items
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
|
||||||
📝 Special Instructions
|
Special instructions (optional)
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="E.g. Extra spicy, no onion, serve starters first..."
|
placeholder="E.g. Extra spicy, no onion, serve starters first…"
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
rows={2}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -176,60 +251,125 @@ export const CartReviewView: React.FC<CartReviewViewProps> = ({
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Bill Summary */}
|
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||||
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
|
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
|
||||||
Bill Summary
|
Bill summary
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ label: 'Items Total', value: `₹${subtotal.toFixed(0)}` },
|
{ label: 'Items total', value: `₹${subtotal.toFixed(0)}` },
|
||||||
{ label: 'CGST (2.5%)', value: `₹${cgst.toFixed(0)}` },
|
{ label: 'CGST (2.5%)', value: `₹${cgst.toFixed(0)}` },
|
||||||
{ label: 'SGST (2.5%)', value: `₹${sgst.toFixed(0)}` },
|
{ label: 'SGST (2.5%)', value: `₹${sgst.toFixed(0)}` },
|
||||||
{ label: 'Service Charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
{ label: 'Service charge (5%)', value: `₹${serviceCharge.toFixed(0)}` },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6 }}>
|
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.55 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
|
<Typography variant="body2" sx={{ color: '#6b5c57' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
|
{row.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{row.value}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Divider sx={{ my: 1.5 }} />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Grand Total</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>₹{grandTotal.toFixed(0)}</Typography>
|
To pay
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||||
|
₹{grandTotal.toFixed(0)}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}>
|
||||||
*Inclusive of all taxes as per GST regulations
|
Inclusive of taxes · Review carefully before placing
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
{/* Place Order CTA */}
|
{cartEntries.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 64,
|
||||||
|
zIndex: 120,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
bgcolor: 'rgba(248,245,242,0.92)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
borderTop: '1px solid #f0ebe7',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="sm" disableGutters>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => onPlaceOrder(orderNotes)}
|
disabled={placing}
|
||||||
|
onClick={() => void handlePlace()}
|
||||||
endIcon={<SendIcon />}
|
endIcon={<SendIcon />}
|
||||||
sx={{
|
sx={{
|
||||||
py: 2,
|
py: 1.6,
|
||||||
borderRadius: '9999px',
|
borderRadius: '9999px',
|
||||||
fontSize: '1.05rem',
|
fontSize: '1rem',
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||||
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
|
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
|
||||||
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
|
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Place Order — ₹{grandTotal.toFixed(0)}
|
{placing ? 'Placing order…' : `Place order · ₹${grandTotal.toFixed(0)}`}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
elevation={8}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
borderTop: '1px solid #f0ebe7',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomNavigation
|
||||||
|
value={2}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
if (v === 0) onNavigate('landing');
|
||||||
|
if (v === 1) onNavigate('menu');
|
||||||
|
if (v === 3) onNavigate('status');
|
||||||
|
}}
|
||||||
|
sx={{ bgcolor: '#ffffff', height: 64 }}
|
||||||
|
>
|
||||||
|
<BottomNavigationAction label="Home" icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||||
|
<BottomNavigationAction label="Menu" icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="Cart"
|
||||||
|
icon={
|
||||||
|
<Badge badgeContent={totalUnits} color="error">
|
||||||
|
<ShoppingCartIcon />
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||||
|
/>
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="Status"
|
||||||
|
icon={<TrackChangesIcon />}
|
||||||
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||||
|
/>
|
||||||
|
</BottomNavigation>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ export const CustomizationModal: React.FC<CustomizationModalProps> = ({
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
const formattedNotes = [
|
const formattedNotes = [
|
||||||
`Spice: ${spiceLevel}`,
|
`Spice: ${spiceLevel}`,
|
||||||
extraCheese ? 'Extra Cheese (+ $2.50)' : '',
|
extraCheese ? 'Extra Cheese (+₹40)' : '',
|
||||||
extraTruffle ? 'Extra Truffle Oil (+ $3.00)' : '',
|
extraTruffle ? 'Extra Truffle Oil (+₹60)' : '',
|
||||||
specialNotes ? `Note: ${specialNotes}` : '',
|
specialNotes ? `Note: ${specialNotes}` : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
BottomNavigation,
|
BottomNavigation,
|
||||||
BottomNavigationAction,
|
BottomNavigationAction,
|
||||||
Button,
|
Button,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
|
@ -27,9 +29,13 @@ import StarIcon from '@mui/icons-material/Star';
|
||||||
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
|
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
|
||||||
import type { MenuItem, CategoryType } from '../../data/menuData';
|
import type { MenuItem, CategoryType } from '../../data/menuData';
|
||||||
import { StatusBadge } from '../common/StatusBadge';
|
import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
import { useLocale } from '../../i18n/LocaleContext';
|
||||||
|
import { CartPeekDrawer } from './CartPeekDrawer';
|
||||||
|
|
||||||
interface MenuBrowseViewProps {
|
interface MenuBrowseViewProps {
|
||||||
cart: { [itemId: string]: number };
|
cart: { [itemId: string]: number };
|
||||||
|
cartNotes?: { [itemId: string]: string };
|
||||||
|
menuItems?: MenuItem[];
|
||||||
onUpdateCart: (itemId: string, quantity: number) => void;
|
onUpdateCart: (itemId: string, quantity: number) => void;
|
||||||
onOpenCustomization: (item: MenuItem) => void;
|
onOpenCustomization: (item: MenuItem) => void;
|
||||||
onOpenVoice: () => void;
|
onOpenVoice: () => void;
|
||||||
|
|
@ -50,26 +56,49 @@ const SPICE_ICONS: Record<string, string> = {
|
||||||
'extra-hot': '🔥',
|
'extra-hot': '🔥',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tableLabel = () =>
|
||||||
|
sessionStorage.getItem('customer_table_number') ||
|
||||||
|
sessionStorage.getItem('customer_table_id') ||
|
||||||
|
'—';
|
||||||
|
|
||||||
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
cart,
|
cart,
|
||||||
|
cartNotes = {},
|
||||||
|
menuItems = MENU_ITEMS,
|
||||||
onUpdateCart,
|
onUpdateCart,
|
||||||
onOpenCustomization,
|
onOpenCustomization,
|
||||||
onOpenVoice,
|
onOpenVoice,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLocale();
|
||||||
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
|
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
|
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
|
||||||
const [bottomNav, setBottomNav] = useState(1); // 1 = Menu
|
const [bottomNav, setBottomNav] = useState(1);
|
||||||
|
const [cartPeekOpen, setCartPeekOpen] = useState(false);
|
||||||
|
const [emptyHint, setEmptyHint] = useState(false);
|
||||||
|
|
||||||
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
|
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
|
||||||
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
|
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
|
||||||
const item = MENU_ITEMS.find((m) => m.id === id);
|
const item = menuItems.find((m) => String(m.id) === id);
|
||||||
return sum + (item ? item.price * qty : 0);
|
return sum + (item ? item.price * qty : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
const cartLines = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(cart)
|
||||||
|
.map(([id, qty]) => {
|
||||||
|
const item = menuItems.find((m) => String(m.id) === id);
|
||||||
|
if (!item) return null;
|
||||||
|
return { item, qty, notes: cartNotes[id] };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[],
|
||||||
|
[cart, cartNotes, menuItems]
|
||||||
|
);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
return MENU_ITEMS.filter((item) => {
|
return menuItems.filter((item) => {
|
||||||
|
if (item.isAvailable === false) return false;
|
||||||
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
|
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
|
||||||
const matchSearch =
|
const matchSearch =
|
||||||
!searchQuery ||
|
!searchQuery ||
|
||||||
|
|
@ -79,20 +108,31 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
|
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
|
||||||
return matchCategory && matchSearch && matchDietary;
|
return matchCategory && matchSearch && matchDietary;
|
||||||
});
|
});
|
||||||
}, [activeCategory, searchQuery, dietaryFilter]);
|
}, [menuItems, activeCategory, searchQuery, dietaryFilter]);
|
||||||
|
|
||||||
|
const openCartCheck = () => {
|
||||||
|
setCartPeekOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCartTap = () => {
|
||||||
|
openCartCheck();
|
||||||
|
if (totalCartItems === 0) setEmptyHint(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
|
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
setBottomNav(newValue);
|
setBottomNav(newValue);
|
||||||
if (newValue === 0) onNavigate('landing');
|
if (newValue === 0) onNavigate('landing');
|
||||||
|
if (newValue === 1) setBottomNav(1);
|
||||||
if (newValue === 2) {
|
if (newValue === 2) {
|
||||||
if (totalCartItems > 0) onNavigate('cart');
|
openCartCheck();
|
||||||
|
if (totalCartItems === 0) setEmptyHint(true);
|
||||||
|
setBottomNav(1);
|
||||||
}
|
}
|
||||||
if (newValue === 3) onNavigate('status');
|
if (newValue === 3) onNavigate('status');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Sticky Header */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
|
|
@ -103,30 +143,27 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
|
||||||
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
|
||||||
RestroAI Menu
|
Menu
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
||||||
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'} · Dine-In
|
Table {tableLabel()} · Tap dishes to add · Check cart anytime
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onOpenVoice}
|
onClick={onOpenVoice}
|
||||||
sx={{
|
aria-label="Voice order"
|
||||||
bgcolor: '#ffdbd1',
|
sx={{ bgcolor: '#ffdbd1', color: '#ac2d00', '&:hover': { bgcolor: '#ffcab8' } }}
|
||||||
color: '#ac2d00',
|
|
||||||
'&:hover': { bgcolor: '#ffcab8' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MicIcon />
|
<MicIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Badge badgeContent={totalCartItems} color="error">
|
<Badge badgeContent={totalCartItems} color="error">
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => totalCartItems > 0 && onNavigate('cart')}
|
onClick={handleCartTap}
|
||||||
|
aria-label="View cart"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
|
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
|
||||||
color: totalCartItems > 0 ? '#fff' : '#8f7068',
|
color: totalCartItems > 0 ? '#fff' : '#8f7068',
|
||||||
|
|
@ -139,12 +176,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<Box sx={{ px: 2, pb: 1 }}>
|
<Box sx={{ px: 2, pb: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search dishes, e.g. biryani, paneer..."
|
placeholder="Search dishes…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|
@ -166,21 +202,26 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Category Tabs - Horizontal Scroll */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 0.8,
|
gap: 0.8,
|
||||||
px: 2,
|
px: 2,
|
||||||
pb: 1.5,
|
pb: 1,
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((cat) => {
|
{CATEGORIES.map((cat) => {
|
||||||
const catEmoji: Record<string, string> = {
|
const catEmoji: Record<string, string> = {
|
||||||
All: '🍽️', Starters: '🥗', Biryani: '🍚', Mains: '🍛',
|
All: '🍽️',
|
||||||
Breads: '🫓', Drinks: '🥤', Desserts: '🍮', Specials: '⭐',
|
Starters: '🥗',
|
||||||
|
Biryani: '🍚',
|
||||||
|
Mains: '🍛',
|
||||||
|
Breads: '🫓',
|
||||||
|
Drinks: '🥤',
|
||||||
|
Desserts: '🍮',
|
||||||
|
Specials: '⭐',
|
||||||
};
|
};
|
||||||
const isActive = activeCategory === cat;
|
const isActive = activeCategory === cat;
|
||||||
return (
|
return (
|
||||||
|
|
@ -195,15 +236,13 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
color: isActive ? '#fff' : '#5b4139',
|
color: isActive ? '#fff' : '#5b4139',
|
||||||
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
|
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
|
||||||
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
|
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dietary Filters */}
|
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.25, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||||
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.5, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
|
|
||||||
{DIETARY_FILTERS.map((f) => (
|
{DIETARY_FILTERS.map((f) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={f.label}
|
key={f.label}
|
||||||
|
|
@ -218,19 +257,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* AI Banner */}
|
<Box sx={{ px: 2, pt: 1.5 }}>
|
||||||
<Box sx={{ px: 2, pt: 2 }}>
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 1.75,
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
mb: 2,
|
mb: 1.5,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={onOpenVoice}
|
onClick={onOpenVoice}
|
||||||
|
|
@ -249,163 +287,140 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
>
|
>
|
||||||
<AutoAwesomeIcon />
|
<AutoAwesomeIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||||
🍛 Today's AI Pick: Hyderabadi Chicken Biryani
|
Order with AI voice
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||||
Tap to voice order or browse below · ₹380
|
Say what you want — we add it to your cart
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Menu Cards */}
|
<Box sx={{ flex: 1, px: 2, pb: totalCartItems > 0 ? 22 : 14 }}>
|
||||||
<Box sx={{ flex: 1, px: 2, pb: 20 }}>
|
|
||||||
{activeCategory !== 'All' && (
|
{activeCategory !== 'All' && (
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
<Typography
|
||||||
{activeCategory} ({filteredItems.length} items)
|
variant="subtitle2"
|
||||||
|
sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
{activeCategory} ({filteredItems.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>No dishes found</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
||||||
<Typography variant="body2" sx={{ color: '#b0a09a' }}>Try a different search or filter</Typography>
|
No dishes found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 2 }}>
|
||||||
|
Try a different search or filter
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setDietaryFilter(null);
|
||||||
|
setActiveCategory('All');
|
||||||
|
}}
|
||||||
|
sx={{ borderRadius: '9999px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const qty = cart[item.id] || 0;
|
const qty = cart[String(item.id)] || 0;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '20px',
|
borderRadius: '16px',
|
||||||
border: '1px solid #f0ebe7',
|
border: qty > 0 ? '1.5px solid #ac2d00' : '1px solid #f0ebe7',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: '#ffffff',
|
bgcolor: '#ffffff',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
display: 'flex',
|
||||||
transition: 'box-shadow 0.2s ease',
|
boxShadow: '0 2px 10px rgba(0,0,0,0.04)',
|
||||||
'&:hover': { boxShadow: '0 6px 20px rgba(0,0,0,0.08)' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Food Image */}
|
<Box sx={{ position: 'relative', width: 112, flexShrink: 0 }}>
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="180"
|
|
||||||
image={item.image}
|
image={item.image}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
sx={{ objectFit: 'cover' }}
|
sx={{ width: 112, height: '100%', minHeight: 112, objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
{/* Badges on image */}
|
{(item.isChefSpecial || item.isBestseller) && (
|
||||||
<Box sx={{ position: 'absolute', top: 10, left: 10, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{item.isChefSpecial && (
|
|
||||||
<Chip
|
<Chip
|
||||||
icon={<AutoAwesomeIcon sx={{ fontSize: '14px !important' }} />}
|
icon={
|
||||||
label="Chef's Special"
|
item.isChefSpecial ? (
|
||||||
size="small"
|
<AutoAwesomeIcon sx={{ fontSize: '12px !important' }} />
|
||||||
sx={{
|
) : (
|
||||||
bgcolor: 'rgba(172,45,0,0.9)',
|
<StarIcon sx={{ fontSize: '12px !important', color: '#FFD700' }} />
|
||||||
color: '#fff',
|
)
|
||||||
fontWeight: 800,
|
}
|
||||||
fontSize: '0.65rem',
|
label={item.isChefSpecial ? 'Special' : 'Hit'}
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.isBestseller && (
|
|
||||||
<Chip
|
|
||||||
icon={<StarIcon sx={{ fontSize: '14px !important', color: '#FFD700' }} />}
|
|
||||||
label="Bestseller"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(0,0,0,0.7)',
|
|
||||||
color: '#FFD700',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{item.spiceLevel && (
|
|
||||||
<Chip
|
|
||||||
label={`${SPICE_ICONS[item.spiceLevel]} ${item.spiceLevel}`}
|
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 10,
|
top: 6,
|
||||||
right: 10,
|
left: 6,
|
||||||
bgcolor: 'rgba(255,255,255,0.9)',
|
height: 22,
|
||||||
fontWeight: 700,
|
fontSize: '0.62rem',
|
||||||
fontSize: '0.65rem',
|
fontWeight: 800,
|
||||||
textTransform: 'capitalize',
|
bgcolor: 'rgba(0,0,0,0.7)',
|
||||||
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Card Content */}
|
<Box sx={{ p: 1.5, flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, mb: 0.35 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
<Typography sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.25, fontSize: '0.95rem' }}>
|
||||||
<Box sx={{ flex: 1, mr: 1 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
{item.nameHindi && (
|
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
|
||||||
{item.nameHindi}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<StatusBadge dietary={item.dietary} />
|
<StatusBadge dietary={item.dietary} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Typography
|
||||||
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 1.5, lineHeight: 1.5, fontSize: '0.8rem' }}>
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: '#6b5c57',
|
||||||
|
mb: 1,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
{/* Tags */}
|
|
||||||
{item.tags && item.tags.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.5 }}>
|
|
||||||
{item.tags.slice(0, 3).map((tag) => (
|
|
||||||
<Chip
|
|
||||||
key={tag}
|
|
||||||
label={tag}
|
|
||||||
size="small"
|
|
||||||
sx={{ fontSize: '0.62rem', fontWeight: 700, bgcolor: '#f8f5f2', height: 20 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Price + Action Row */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
<Typography sx={{ fontWeight: 900, color: '#ac2d00', fontSize: '1rem' }}>₹{item.price}</Typography>
|
||||||
₹{item.price}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||||
⏱ {item.prepTimeMinutes} min
|
{item.spiceLevel ? `${SPICE_ICONS[item.spiceLevel] || ''} ` : ''}
|
||||||
|
{item.prepTimeMinutes} min
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{qty === 0 ? (
|
{qty === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
onClick={() => onOpenCustomization(item)}
|
onClick={() => onOpenCustomization(item)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '9999px',
|
borderRadius: '9999px',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: '#ac2d00',
|
bgcolor: '#ac2d00',
|
||||||
borderColor: '#ac2d00',
|
px: 1.75,
|
||||||
px: 2,
|
boxShadow: 'none',
|
||||||
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
|
'&:hover': { bgcolor: '#872100', boxShadow: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
|
|
@ -422,18 +437,18 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onUpdateCart(item.id, qty - 1)}
|
onClick={() => onUpdateCart(String(item.id), qty - 1)}
|
||||||
sx={{ color: '#fff', p: 0.8 }}
|
sx={{ color: '#fff', p: 0.7 }}
|
||||||
>
|
>
|
||||||
<RemoveIcon fontSize="small" />
|
<RemoveIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography sx={{ px: 1.5, color: '#fff', fontWeight: 900, fontFamily: 'monospace' }}>
|
<Typography sx={{ px: 1.25, color: '#fff', fontWeight: 900, minWidth: 20, textAlign: 'center' }}>
|
||||||
{qty}
|
{qty}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onUpdateCart(item.id, qty + 1)}
|
onClick={() => onUpdateCart(String(item.id), qty + 1)}
|
||||||
sx={{ color: '#fff', p: 0.8 }}
|
sx={{ color: '#fff', p: 0.7 }}
|
||||||
>
|
>
|
||||||
<AddIcon fontSize="small" />
|
<AddIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -448,53 +463,66 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Floating Cart Pill */}
|
|
||||||
{totalCartItems > 0 && (
|
{totalCartItems > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 72,
|
bottom: 72,
|
||||||
left: '50%',
|
left: 12,
|
||||||
transform: 'translateX(-50%)',
|
right: 12,
|
||||||
zIndex: 200,
|
zIndex: 200,
|
||||||
|
maxWidth: 520,
|
||||||
|
mx: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={8}
|
elevation={10}
|
||||||
onClick={() => onNavigate('cart')}
|
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'stretch',
|
||||||
gap: 2,
|
borderRadius: '16px',
|
||||||
px: 3,
|
overflow: 'hidden',
|
||||||
py: 1.5,
|
bgcolor: '#1a1c1c',
|
||||||
borderRadius: '9999px',
|
|
||||||
bgcolor: '#ac2d00',
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
cursor: 'pointer',
|
boxShadow: '0 10px 28px rgba(0,0,0,0.28)',
|
||||||
boxShadow: '0 8px 28px rgba(172,45,0,0.45)',
|
|
||||||
'&:hover': { bgcolor: '#872100' },
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
minWidth: 260,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box
|
||||||
<Badge badgeContent={totalCartItems} color="warning" sx={{ '& .MuiBadge-badge': { fontWeight: 800 } }}>
|
onClick={openCartCheck}
|
||||||
<ShoppingCartIcon />
|
sx={{
|
||||||
</Badge>
|
flex: 1,
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
px: 2,
|
||||||
{totalCartItems} item{totalCartItems > 1 ? 's' : ''} in cart
|
py: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.8, fontWeight: 700 }}>
|
||||||
|
View cart · {totalCartItems} item{totalCartItems === 1 ? '' : 's'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1.05rem' }}>₹{totalCartValue.toFixed(0)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>
|
<Button
|
||||||
₹{totalCartValue.toFixed(0)} →
|
onClick={() => onNavigate('cart')}
|
||||||
</Typography>
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
borderRadius: 0,
|
||||||
|
bgcolor: '#ac2d00',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 900,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
'&:hover': { bgcolor: '#872100' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Checkout →
|
||||||
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={8}
|
elevation={8}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -506,23 +534,11 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
borderTop: '1px solid #f0ebe7',
|
borderTop: '1px solid #f0ebe7',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BottomNavigation
|
<BottomNavigation value={bottomNav} onChange={handleBottomNav} sx={{ bgcolor: '#ffffff', height: 64 }}>
|
||||||
value={bottomNav}
|
<BottomNavigationAction label={t('brand')} icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||||
onChange={handleBottomNav}
|
<BottomNavigationAction label={t('menu')} icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
||||||
sx={{ bgcolor: '#ffffff', height: 64 }}
|
|
||||||
>
|
|
||||||
<BottomNavigationAction
|
<BottomNavigationAction
|
||||||
label="Home"
|
label={t('cart')}
|
||||||
icon={<HomeIcon />}
|
|
||||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
|
||||||
/>
|
|
||||||
<BottomNavigationAction
|
|
||||||
label="Menu"
|
|
||||||
icon={<MenuBookIcon />}
|
|
||||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
|
||||||
/>
|
|
||||||
<BottomNavigationAction
|
|
||||||
label="Cart"
|
|
||||||
icon={
|
icon={
|
||||||
<Badge badgeContent={totalCartItems} color="error">
|
<Badge badgeContent={totalCartItems} color="error">
|
||||||
<ShoppingCartIcon />
|
<ShoppingCartIcon />
|
||||||
|
|
@ -531,12 +547,37 @@ export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
||||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||||
/>
|
/>
|
||||||
<BottomNavigationAction
|
<BottomNavigationAction
|
||||||
label="My Order"
|
label={t('orderStatus')}
|
||||||
icon={<TrackChangesIcon />}
|
icon={<TrackChangesIcon />}
|
||||||
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
||||||
/>
|
/>
|
||||||
</BottomNavigation>
|
</BottomNavigation>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<CartPeekDrawer
|
||||||
|
open={cartPeekOpen}
|
||||||
|
lines={cartLines}
|
||||||
|
subtotal={totalCartValue}
|
||||||
|
onClose={() => setCartPeekOpen(false)}
|
||||||
|
onUpdateCart={onUpdateCart}
|
||||||
|
onContinueShopping={() => setCartPeekOpen(false)}
|
||||||
|
onCheckout={() => {
|
||||||
|
setCartPeekOpen(false);
|
||||||
|
onNavigate('cart');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={emptyHint}
|
||||||
|
autoHideDuration={2800}
|
||||||
|
onClose={() => setEmptyHint(false)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
sx={{ bottom: { xs: 140, sm: 140 } }}
|
||||||
|
>
|
||||||
|
<Alert severity="info" onClose={() => setEmptyHint(false)} sx={{ fontWeight: 700, borderRadius: '12px' }}>
|
||||||
|
Cart is empty — add a dish, then check it here anytime.
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,15 @@ import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
||||||
import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt';
|
import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt';
|
||||||
import { api, RestroWebSocket, subscribeToWsEvents } from '../../services/api';
|
import { api, RestroWebSocket, subscribeToWsEvents } from '../../services/api';
|
||||||
|
|
||||||
|
interface PlacedItem {
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface OrderStatusViewProps {
|
interface OrderStatusViewProps {
|
||||||
|
placedItems?: PlacedItem[];
|
||||||
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,14 +61,34 @@ const ORDER_STEPS = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate }) => {
|
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({
|
||||||
|
placedItems = [],
|
||||||
|
onNavigate,
|
||||||
|
}) => {
|
||||||
const [activeStep, setActiveStep] = useState<number>(0);
|
const [activeStep, setActiveStep] = useState<number>(0);
|
||||||
const [ticketNum, setTicketNum] = useState<string>('---');
|
const [ticketNum, setTicketNum] = useState<string>('---');
|
||||||
const [tableNum, setTableNum] = useState<string>('12');
|
const [tableNum, setTableNum] = useState<string>(
|
||||||
|
() =>
|
||||||
|
sessionStorage.getItem('customer_table_number') ||
|
||||||
|
sessionStorage.getItem('customer_table_id') ||
|
||||||
|
'—'
|
||||||
|
);
|
||||||
const [waiterNotified, setWaiterNotified] = useState<boolean>(false);
|
const [waiterNotified, setWaiterNotified] = useState<boolean>(false);
|
||||||
const [eta, setEta] = useState(15);
|
const [eta, setEta] = useState(15);
|
||||||
|
|
||||||
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
|
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
|
||||||
|
const summaryItems =
|
||||||
|
placedItems.length > 0
|
||||||
|
? placedItems
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem('customer_last_order_items');
|
||||||
|
return raw ? (JSON.parse(raw) as PlacedItem[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [] as PlacedItem[];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const summaryTotal = summaryItems.reduce((s, i) => s + i.qty * i.price, 0);
|
||||||
|
|
||||||
const loadOrder = async () => {
|
const loadOrder = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,33 +239,54 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Ordered Items Summary */}
|
|
||||||
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1.5 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1.5 }}>
|
||||||
🧾 Your Order Summary
|
Your order summary
|
||||||
</Typography>
|
</Typography>
|
||||||
{[
|
{summaryItems.length === 0 ? (
|
||||||
{ name: 'Hyderabadi Chicken Biryani', qty: 1, price: 380 },
|
<Typography variant="body2" sx={{ color: '#8f7068' }}>
|
||||||
{ name: 'Paneer Tikka', qty: 1, price: 280 },
|
Order details will appear here after you place an order from the cart.
|
||||||
{ name: 'Garlic Naan', qty: 2, price: 80 },
|
</Typography>
|
||||||
{ name: 'Mango Lassi', qty: 2, price: 120 },
|
) : (
|
||||||
].map((item, idx) => (
|
<>
|
||||||
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6, borderBottom: idx < 3 ? '1px solid #f5f0ec' : 'none' }}>
|
{summaryItems.map((item, idx) => (
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Box
|
||||||
|
key={`${item.name}-${idx}`}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
py: 0.75,
|
||||||
|
borderBottom: idx < summaryItems.length - 1 ? '1px solid #f5f0ec' : 'none',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
{item.qty}× {item.name}
|
{item.qty}× {item.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
{item.notes ? (
|
||||||
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
||||||
|
{item.notes}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', flexShrink: 0 }}>
|
||||||
₹{item.qty * item.price}
|
₹{item.qty * item.price}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, pt: 1, borderTop: '2px solid #f0ebe7' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, pt: 1, borderTop: '2px solid #f0ebe7' }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Estimated Total</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>₹960</Typography>
|
Items total
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>
|
||||||
|
₹{summaryTotal.toFixed(0)}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -254,7 +303,7 @@ export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate })
|
||||||
'&:hover': { borderColor: '#845000', bgcolor: '#fff8ee' },
|
'&:hover': { borderColor: '#845000', bgcolor: '#fff8ee' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Call Waiter to Table 12
|
Call waiter to Table {tableNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ import {
|
||||||
Chip,
|
Chip,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
|
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
|
@ -17,44 +25,83 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import WifiIcon from '@mui/icons-material/Wifi';
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
import MicIcon from '@mui/icons-material/Mic';
|
import MicIcon from '@mui/icons-material/Mic';
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import BadgeIcon from '@mui/icons-material/Badge';
|
||||||
|
import { useLocale } from '../../i18n/LocaleContext';
|
||||||
|
import { uiLangToLocale } from '../../i18n/messages';
|
||||||
|
|
||||||
|
export type GuestTableOption = {
|
||||||
|
id: number;
|
||||||
|
table_number: string;
|
||||||
|
capacity: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface TableLandingViewProps {
|
interface TableLandingViewProps {
|
||||||
onStartOrdering: (language: string) => void;
|
onStartOrdering: (language: 'english' | 'hindi' | 'hinglish') => void;
|
||||||
onOpenStaffLogin?: () => void;
|
onOpenStaffLogin?: () => void;
|
||||||
onOpenVoice?: () => void;
|
onOpenVoice?: () => void;
|
||||||
|
selectedTable: GuestTableOption | null;
|
||||||
|
tableLocked: boolean;
|
||||||
|
availableTables: GuestTableOption[];
|
||||||
|
tablesLoading?: boolean;
|
||||||
|
tablesError?: string | null;
|
||||||
|
onSelectTable: (table: GuestTableOption) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
onStartOrdering,
|
onStartOrdering,
|
||||||
onOpenStaffLogin,
|
onOpenStaffLogin,
|
||||||
onOpenVoice,
|
onOpenVoice,
|
||||||
|
selectedTable,
|
||||||
|
tableLocked,
|
||||||
|
availableTables,
|
||||||
|
tablesLoading = false,
|
||||||
|
tablesError = null,
|
||||||
|
onSelectTable,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t, setLocale } = useLocale();
|
||||||
const [selectedLang, setSelectedLang] = useState<'english' | 'hindi' | 'hinglish'>('english');
|
const [selectedLang, setSelectedLang] = useState<'english' | 'hindi' | 'hinglish'>('english');
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{
|
{
|
||||||
id: 'english',
|
id: 'english',
|
||||||
emoji: '🇬🇧',
|
emoji: '🇬🇧',
|
||||||
name: 'English',
|
name: 'English',
|
||||||
subText: 'English',
|
|
||||||
icon: <LanguageIcon fontSize="large" />,
|
icon: <LanguageIcon fontSize="large" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hindi',
|
id: 'hindi',
|
||||||
emoji: '🇮🇳',
|
emoji: '🇮🇳',
|
||||||
name: 'हिन्दी',
|
name: 'हिन्दी',
|
||||||
subText: 'Hindi',
|
|
||||||
icon: <Typography variant="h5" sx={{ fontWeight: 900, lineHeight: 1 }}>नमस्ते</Typography>,
|
icon: <Typography variant="h5" sx={{ fontWeight: 900, lineHeight: 1 }}>नमस्ते</Typography>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hinglish',
|
id: 'hinglish',
|
||||||
emoji: '🤝',
|
emoji: '🤝',
|
||||||
name: 'Hinglish',
|
name: 'Hinglish',
|
||||||
subText: 'Mix',
|
|
||||||
icon: <TranslateIcon fontSize="large" />,
|
icon: <TranslateIcon fontSize="large" />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tableLabel = selectedTable
|
||||||
|
? `${t('tableLabel')} ${selectedTable.table_number}`
|
||||||
|
: t('selectTable');
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
if (tableLocked) return;
|
||||||
|
setPickerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!selectedTable && !tableLocked) {
|
||||||
|
setPickerOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStartOrdering(selectedLang);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -67,7 +114,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Hero Image Banner */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: 260,
|
height: 260,
|
||||||
|
|
@ -78,7 +124,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Dark overlay gradient */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -87,7 +132,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top bar inside hero */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -118,26 +162,23 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
<RestaurantMenuIcon fontSize="small" />
|
<RestaurantMenuIcon fontSize="small" />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, color: '#fff', letterSpacing: '-0.02em' }}>
|
<Typography variant="h6" sx={{ fontWeight: 900, color: '#fff', letterSpacing: '-0.02em' }}>
|
||||||
RestroAI
|
{t('brand')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={`🪑 Table ${(() => {
|
clickable={!tableLocked}
|
||||||
const params = new URLSearchParams(window.location.search);
|
onClick={openPicker}
|
||||||
const tableParam = params.get('table');
|
icon={
|
||||||
if (tableParam) {
|
tableLocked ? (
|
||||||
const parsed = parseInt(tableParam);
|
<LockIcon sx={{ color: '#fff !important', fontSize: 16 }} />
|
||||||
if (!isNaN(parsed)) return parsed;
|
) : (
|
||||||
|
<ExpandMoreIcon sx={{ color: '#fff !important', fontSize: 18 }} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const path = window.location.pathname;
|
label={tablesLoading ? '…' : `🪑 ${tableLabel}`}
|
||||||
const match = path.match(/\/scan\/(\d+)/);
|
title={tableLocked ? t('tableLockedHint') : t('tablePickHint')}
|
||||||
if (match) {
|
|
||||||
return parseInt(match[1]);
|
|
||||||
}
|
|
||||||
return 12;
|
|
||||||
})()}`}
|
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontFamily: '"JetBrains Mono", monospace',
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
|
|
@ -145,15 +186,21 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
border: '1px solid rgba(255,255,255,0.35)',
|
border: '1px solid rgba(255,255,255,0.35)',
|
||||||
|
cursor: tableLocked ? 'default' : 'pointer',
|
||||||
|
'& .MuiChip-icon': { ml: 0.5 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton size="small" onClick={onOpenStaffLogin} sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
<IconButton
|
||||||
<LockIcon fontSize="small" />
|
size="small"
|
||||||
|
onClick={() => (onOpenStaffLogin ? onOpenStaffLogin() : (window.location.href = '/staff.html'))}
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
aria-label={t('staffLogin')}
|
||||||
|
>
|
||||||
|
<BadgeIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Hero text at bottom of image */}
|
|
||||||
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, px: 2.5, pb: 2.5 }}>
|
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, px: 2.5, pb: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 0.5 }}>
|
||||||
<AutoAwesomeIcon sx={{ fontSize: 14, color: '#ffb5a0' }} />
|
<AutoAwesomeIcon sx={{ fontSize: 14, color: '#ffb5a0' }} />
|
||||||
|
|
@ -170,10 +217,48 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<Box sx={{ flex: 1, overflow: 'auto', pb: 4 }}>
|
<Box sx={{ flex: 1, overflow: 'auto', pb: 4 }}>
|
||||||
<Container maxWidth="xs" sx={{ pt: 3 }}>
|
<Container maxWidth="xs" sx={{ pt: 3 }}>
|
||||||
{/* AI Voice Section */}
|
{tablesError && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
{tablesError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!tableLocked && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
onClick={openPicker}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1.5px dashed #e4beb4',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068', textTransform: 'uppercase' }}>
|
||||||
|
{t('selectTable')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||||
|
{selectedTable ? `Table ${selectedTable.table_number}` : t('tablePickHint')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<ExpandMoreIcon sx={{ color: '#ac2d00' }} />
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tableLocked && selectedTable && (
|
||||||
|
<Alert severity="info" icon={<LockIcon />} sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
{t('tableLockedHint')} — Table {selectedTable.table_number}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -201,14 +286,13 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: '0 4px 12px rgba(172,45,0,0.4)',
|
boxShadow: '0 4px 12px rgba(172,45,0,0.4)',
|
||||||
animation: 'pulse 2.5s ease-in-out infinite',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MicIcon />
|
<MicIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', lineHeight: 1.2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', lineHeight: 1.2 }}>
|
||||||
Order with Voice 🎙️
|
{t('voiceOrder')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
|
||||||
Tap & say your order in English or Hindi
|
Tap & say your order in English or Hindi
|
||||||
|
|
@ -218,7 +302,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Language Selection */}
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|
@ -231,7 +314,7 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Choose Your Language
|
{t('chooseLanguage')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Grid container spacing={1.5}>
|
<Grid container spacing={1.5}>
|
||||||
|
|
@ -241,7 +324,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
<Grid size={{ xs: 4 }} key={lang.id}>
|
<Grid size={{ xs: 4 }} key={lang.id}>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={isSelected ? 3 : 0}
|
elevation={isSelected ? 3 : 0}
|
||||||
onClick={() => setSelectedLang(lang.id as 'english' | 'hindi' | 'hinglish')}
|
onClick={() => {
|
||||||
|
const next = lang.id as 'english' | 'hindi' | 'hinglish';
|
||||||
|
setSelectedLang(next);
|
||||||
|
setLocale(uiLangToLocale(next));
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
|
|
@ -271,7 +358,6 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Today's Highlights */}
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -300,12 +386,11 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Main CTA Button */}
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => onStartOrdering(selectedLang)}
|
onClick={handleStart}
|
||||||
endIcon={<ArrowForwardIcon />}
|
endIcon={<ArrowForwardIcon />}
|
||||||
sx={{
|
sx={{
|
||||||
py: 2,
|
py: 2,
|
||||||
|
|
@ -321,10 +406,9 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
transition: 'all 0.25s ease',
|
transition: 'all 0.25s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Browse Full Menu
|
{t('startOrdering')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 1, color: '#8f7068' }}>
|
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 1, color: '#8f7068' }}>
|
||||||
<WifiIcon fontSize="small" />
|
<WifiIcon fontSize="small" />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
|
@ -333,6 +417,39 @@ export const TableLandingView: React.FC<TableLandingViewProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={pickerOpen} onClose={() => setPickerOpen(false)} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 900 }}>{t('selectTable')}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{tablesLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!tablesLoading && availableTables.length === 0 && (
|
||||||
|
<Alert severity="warning">{t('noTablesAvailable')}</Alert>
|
||||||
|
)}
|
||||||
|
<List disablePadding>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={table.id}
|
||||||
|
selected={selectedTable?.id === table.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectTable(table);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
sx={{ borderRadius: 2, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={`Table ${table.table_number}`}
|
||||||
|
secondary={`Seats ${table.capacity} · ${table.status}`}
|
||||||
|
primaryTypographyProps={{ fontWeight: 800 }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Fade,
|
Fade,
|
||||||
|
CircularProgress,
|
||||||
|
TextField,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import MicIcon from '@mui/icons-material/Mic';
|
import MicIcon from '@mui/icons-material/Mic';
|
||||||
|
|
@ -18,58 +20,32 @@ import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
|
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
|
||||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import { MENU_ITEMS } from '../../data/menuData';
|
|
||||||
import type { MenuItem } from '../../data/menuData';
|
|
||||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||||
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
|
|
||||||
import type { VoiceState } from '../../hooks/useSpeechRecognition';
|
import type { VoiceState } from '../../hooks/useSpeechRecognition';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { debugLog, maskToken } from '../../utils/debugLog';
|
||||||
|
|
||||||
interface DetectedItem {
|
export type AiCartLine = {
|
||||||
menuItem: MenuItem;
|
menu_item_id: number;
|
||||||
qty: number;
|
name: string;
|
||||||
}
|
quantity: number;
|
||||||
|
unit_price: number | string;
|
||||||
|
notes: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface VoiceAssistantModalProps {
|
interface VoiceAssistantModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddToCart: (itemId: string, quantity: number) => void;
|
onAddToCart: (itemId: string, quantity: number) => void;
|
||||||
}
|
onSyncCart?: (cart: AiCartLine[]) => void;
|
||||||
|
|
||||||
// Detect menu items from voice transcript
|
|
||||||
function parseTranscriptToItems(text: string): DetectedItem[] {
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
const detectedItems: DetectedItem[] = [];
|
|
||||||
|
|
||||||
for (const item of MENU_ITEMS) {
|
|
||||||
const itemNameLower = item.name.toLowerCase();
|
|
||||||
// Check for item name in transcript
|
|
||||||
if (lower.includes(itemNameLower) || (item.nameHindi && lower.includes(item.nameHindi.toLowerCase()))) {
|
|
||||||
// Try to detect quantity (look for digits near the item name)
|
|
||||||
let qty = 1;
|
|
||||||
const qtxMatch = lower.match(/(\d+)\s*(?:plate|order|piece|nos|number)?(?:of\s+)?(?:\w+\s+)*/);
|
|
||||||
if (qtxMatch) {
|
|
||||||
const num = parseInt(qtxMatch[1], 10);
|
|
||||||
if (num >= 1 && num <= 10) qty = num;
|
|
||||||
}
|
|
||||||
if (/\btwo\b|\bdo\b/i.test(lower)) qty = 2;
|
|
||||||
if (/\bthree\b|\bteen\b/i.test(lower)) qty = 3;
|
|
||||||
if (/\bfour\b|\bchar\b/i.test(lower)) qty = 4;
|
|
||||||
|
|
||||||
// avoid duplicates
|
|
||||||
if (!detectedItems.find((d) => d.menuItem.id === item.id)) {
|
|
||||||
detectedItems.push({ menuItem: item, qty });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return detectedItems;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_COMMANDS = [
|
const QUICK_COMMANDS = [
|
||||||
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken and 2 garlic naan please' },
|
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken kam spicy and 2 garlic naan please' },
|
||||||
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani for table 12' },
|
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani please' },
|
||||||
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi" },
|
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi please" },
|
||||||
{ label: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
|
{ label: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
|
||||||
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
|
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
|
||||||
];
|
];
|
||||||
|
|
@ -78,11 +54,118 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onAddToCart,
|
onAddToCart,
|
||||||
|
onSyncCart,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalVoiceState, setInternalVoiceState] = useState<VoiceState>('idle');
|
const [cartLines, setCartLines] = useState<AiCartLine[]>([]);
|
||||||
const [detectedItems, setDetectedItems] = useState<DetectedItem[]>([]);
|
const [assistantText, setAssistantText] = useState('');
|
||||||
|
const [toolCalls, setToolCalls] = useState<string[]>([]);
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
|
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
|
||||||
|
const [liveTranscript, setLiveTranscript] = useState('');
|
||||||
|
const [typedOrder, setTypedOrder] = useState('');
|
||||||
|
|
||||||
|
const applyCart = useCallback(
|
||||||
|
(cart: AiCartLine[]) => {
|
||||||
|
setCartLines(cart);
|
||||||
|
if (onSyncCart) {
|
||||||
|
onSyncCart(cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const line of cart) {
|
||||||
|
onAddToCart(String(line.menu_item_id), line.quantity);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAddToCart, onSyncCart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runAiTurn = useCallback(
|
||||||
|
async (text: string, submit = false) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
debugLog.warn('voice', 'runAiTurn skipped — empty text');
|
||||||
|
setApiError('Say or type an order first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (busyRef.current) {
|
||||||
|
debugLog.warn('voice', 'runAiTurn skipped — already busy', { trimmed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = sessionStorage.getItem('customer_session_token');
|
||||||
|
if (!sessionToken) {
|
||||||
|
debugLog.error('voice', 'runAiTurn blocked — no session token');
|
||||||
|
setApiError('No table session — close this, pick a table, then Start ordering.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busyRef.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setApiError(null);
|
||||||
|
setAssistantText('');
|
||||||
|
setToolCalls([]);
|
||||||
|
setConfirmed(false);
|
||||||
|
setLiveTranscript(trimmed);
|
||||||
|
|
||||||
|
const looksHindi = /[\u0900-\u097F]/.test(trimmed);
|
||||||
|
const language = looksHindi || selectedLang.startsWith('hi') ? 'hi' : 'en';
|
||||||
|
const started = performance.now();
|
||||||
|
debugLog.info('voice', 'runAiTurn start', {
|
||||||
|
submit,
|
||||||
|
language,
|
||||||
|
selectedLang,
|
||||||
|
transcript: trimmed.slice(0, 160),
|
||||||
|
sessionToken: maskToken(sessionToken),
|
||||||
|
tableId: sessionStorage.getItem('customer_table_id'),
|
||||||
|
tableNumber: sessionStorage.getItem('customer_table_number'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.aiChatTurn({ transcript: trimmed, language, submit });
|
||||||
|
|
||||||
|
debugLog.info('voice', 'runAiTurn success', {
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
tools: result.tool_calls,
|
||||||
|
cart: (result.cart || []).map((c) => `${c.quantity}×${c.name}`),
|
||||||
|
orderId: result.order?.order_id,
|
||||||
|
assistant: (result.assistant_text || '').slice(0, 120),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLiveTranscript(result.transcript || trimmed);
|
||||||
|
setAssistantText(result.assistant_text || '');
|
||||||
|
setToolCalls(result.tool_calls || []);
|
||||||
|
if (result.order) {
|
||||||
|
setConfirmed(true);
|
||||||
|
applyCart([]);
|
||||||
|
} else {
|
||||||
|
applyCart(result.cart || []);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const raw = err instanceof Error ? err.message : 'AI ordering failed';
|
||||||
|
const message =
|
||||||
|
raw.length > 160 || raw.includes('failed_generation') || raw.includes('tool_use_failed')
|
||||||
|
? 'AI ordering failed — please try again or use the menu.'
|
||||||
|
: raw;
|
||||||
|
debugLog.error('voice', 'runAiTurn failed', {
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
raw: String(raw).slice(0, 300),
|
||||||
|
shown: message,
|
||||||
|
});
|
||||||
|
setApiError(message);
|
||||||
|
} finally {
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyCart, selectedLang]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runAiTurnRef = useRef(runAiTurn);
|
||||||
|
useEffect(() => {
|
||||||
|
runAiTurnRef.current = runAiTurn;
|
||||||
|
}, [runAiTurn]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
voiceState: recognitionState,
|
voiceState: recognitionState,
|
||||||
|
|
@ -93,101 +176,106 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
stopListening,
|
stopListening,
|
||||||
resetTranscript,
|
resetTranscript,
|
||||||
error,
|
error,
|
||||||
} = useSpeechRecognition();
|
} = useSpeechRecognition((finalText) => {
|
||||||
|
debugLog.info('stt', 'final transcript → AI', { text: finalText.slice(0, 160) });
|
||||||
|
void runAiTurnRef.current(finalText, false);
|
||||||
|
});
|
||||||
|
|
||||||
const { speak, cancel } = useSpeechSynthesis((s) => setInternalVoiceState(s));
|
|
||||||
|
|
||||||
const voiceState = internalVoiceState !== 'idle' ? internalVoiceState : recognitionState;
|
|
||||||
|
|
||||||
// Parse items when transcript updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (transcript.trim().length > 0 && recognitionState === 'thinking') {
|
if (!open) return;
|
||||||
const items = parseTranscriptToItems(transcript);
|
debugLog.info('voice', 'modal opened', {
|
||||||
setDetectedItems(items);
|
isSupported,
|
||||||
setInternalVoiceState('thinking');
|
|
||||||
|
|
||||||
// AI confirmation response via TTS
|
|
||||||
if (items.length > 0) {
|
|
||||||
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
|
|
||||||
setTimeout(() => {
|
|
||||||
speak(
|
|
||||||
`Got it! I found ${items.length} item${items.length > 1 ? 's' : ''}: ${itemList}. Please confirm to add them to your cart.`,
|
|
||||||
selectedLang,
|
selectedLang,
|
||||||
() => setInternalVoiceState('idle')
|
hasSession: Boolean(sessionStorage.getItem('customer_session_token')),
|
||||||
);
|
tableId: sessionStorage.getItem('customer_table_id'),
|
||||||
}, 500);
|
tableNumber: sessionStorage.getItem('customer_table_number'),
|
||||||
} else {
|
debug: debugLog.enabled(),
|
||||||
setTimeout(() => {
|
});
|
||||||
speak(
|
busyRef.current = false;
|
||||||
"Sorry, I couldn't identify any items from your order. Please try again or use the quick commands below.",
|
setBusy(false);
|
||||||
selectedLang,
|
setApiError(null);
|
||||||
() => setInternalVoiceState('idle')
|
setAssistantText('');
|
||||||
);
|
setToolCalls([]);
|
||||||
setInternalVoiceState('idle');
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [transcript, recognitionState]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
cancel();
|
|
||||||
resetTranscript();
|
|
||||||
setDetectedItems([]);
|
|
||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
setInternalVoiceState('idle');
|
setLiveTranscript('');
|
||||||
|
setTypedOrder('');
|
||||||
|
setCartLines([]);
|
||||||
|
resetTranscript();
|
||||||
|
}, [open, resetTranscript, isSupported, selectedLang]);
|
||||||
|
|
||||||
|
const voiceState: VoiceState = busy
|
||||||
|
? 'thinking'
|
||||||
|
: recognitionState === 'listening'
|
||||||
|
? 'listening'
|
||||||
|
: 'idle';
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
stopListening();
|
||||||
|
resetTranscript();
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
}, [cancel, resetTranscript, onClose]);
|
};
|
||||||
|
|
||||||
const handleMicToggle = () => {
|
const handleMicToggle = () => {
|
||||||
if (voiceState === 'listening') {
|
if (busyRef.current) {
|
||||||
|
debugLog.warn('stt', 'mic toggle ignored — AI busy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSupported) {
|
||||||
|
debugLog.warn('stt', 'mic unsupported');
|
||||||
|
setApiError('Speech recognition is unavailable here. Type your order or tap a quick command.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recognitionState === 'listening') {
|
||||||
|
debugLog.info('stt', 'mic stop requested');
|
||||||
stopListening();
|
stopListening();
|
||||||
} else {
|
return;
|
||||||
setDetectedItems([]);
|
}
|
||||||
setConfirmed(false);
|
debugLog.info('stt', 'mic start', { lang: selectedLang });
|
||||||
|
setApiError(null);
|
||||||
resetTranscript();
|
resetTranscript();
|
||||||
startListening(selectedLang);
|
startListening(selectedLang);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickCommand = (text: string) => {
|
const handleQuickCommand = (text: string) => {
|
||||||
const items = parseTranscriptToItems(text);
|
debugLog.info('voice', 'quick command', { text: text.slice(0, 120) });
|
||||||
setDetectedItems(items);
|
void runAiTurn(text, false);
|
||||||
setInternalVoiceState('thinking');
|
};
|
||||||
if (items.length > 0) {
|
|
||||||
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
|
const handleTypedSubmit = () => {
|
||||||
speak(
|
debugLog.info('voice', 'typed submit', { text: typedOrder.slice(0, 120) });
|
||||||
`Great choice! Found ${itemList}. Tap confirm to add to your cart.`,
|
void runAiTurn(typedOrder, false);
|
||||||
selectedLang,
|
|
||||||
() => setInternalVoiceState('idle')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
detectedItems.forEach((d) => onAddToCart(d.menuItem.id, d.qty));
|
const text =
|
||||||
setConfirmed(true);
|
liveTranscript.trim() ||
|
||||||
speak('Items added to your cart! Enjoy your meal.', selectedLang);
|
'Please place my current cart as an order. I confirm submit.';
|
||||||
setTimeout(() => {
|
void runAiTurn(text, true);
|
||||||
handleClose();
|
|
||||||
}, 2000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const orbColor = {
|
const orbColor = {
|
||||||
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' },
|
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' },
|
||||||
listening: { bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)', shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)' },
|
listening: {
|
||||||
|
bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)',
|
||||||
|
shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)',
|
||||||
|
},
|
||||||
thinking: { bg: 'linear-gradient(135deg, #845000, #b07000)', shadow: '0 8px 24px rgba(132,80,0,0.5)' },
|
thinking: { bg: 'linear-gradient(135deg, #845000, #b07000)', shadow: '0 8px 24px rgba(132,80,0,0.5)' },
|
||||||
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 0 0 16px rgba(0,106,46,0.2), 0 0 0 32px rgba(0,106,46,0.1)' },
|
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 8px 24px rgba(0,106,46,0.4)' },
|
||||||
error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
|
error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const orbLabel = {
|
const orbLabel = {
|
||||||
idle: 'Tap to Speak',
|
idle: isSupported ? 'Tap to Speak' : 'Type or use quick commands',
|
||||||
listening: '🎙️ Listening...',
|
listening: 'Listening… tap again to stop',
|
||||||
thinking: '🤔 Processing...',
|
thinking: 'AI thinking…',
|
||||||
speaking: '🔊 AI Speaking...',
|
speaking: 'Done',
|
||||||
error: '⚠️ Try Again',
|
error: 'Try Again',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shownTranscript = liveTranscript || transcript || interimTranscript;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
|
|
@ -205,12 +293,10 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}>
|
||||||
{/* Handle Bar */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
|
||||||
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
|
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
|
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
|
||||||
|
|
@ -219,7 +305,7 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
AI Voice Assistant
|
AI Voice Assistant
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}>
|
||||||
Speak your order naturally in English or Hindi
|
Type, tap a quick command, or speak → Groq fills your cart
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -228,51 +314,52 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Scrollable Body */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
|
||||||
{!isSupported && (
|
{!isSupported && (
|
||||||
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}>
|
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}>
|
||||||
Voice recognition requires Google Chrome browser. You can still use quick commands below.
|
Speech recognition API is missing in this browser. Type your order or use a quick command.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{isSupported && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
Mic tip (Chrome): tap the orange button, speak, then tap again (or Stop) to send the order to AI.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || apiError) && (
|
||||||
<Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
|
<Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
|
||||||
{error}
|
{apiError || error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voice Orb */}
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
||||||
<Box
|
<Box
|
||||||
onClick={handleMicToggle}
|
onClick={handleMicToggle}
|
||||||
sx={{
|
sx={{
|
||||||
width: 120,
|
width: 112,
|
||||||
height: 120,
|
height: 112,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: orbColor[voiceState].bg,
|
background: orbColor[voiceState].bg,
|
||||||
boxShadow: orbColor[voiceState].shadow,
|
boxShadow: orbColor[voiceState].shadow,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: busy ? 'wait' : 'pointer',
|
||||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
mb: 1.5,
|
||||||
transform: voiceState === 'listening' || voiceState === 'speaking' ? 'scale(1.12)' : 'scale(1)',
|
pointerEvents: busy ? 'none' : 'auto',
|
||||||
mb: 2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{voiceState === 'listening' ? (
|
{busy ? (
|
||||||
<GraphicEqIcon sx={{ color: '#fff', fontSize: 52 }} />
|
<CircularProgress size={40} sx={{ color: '#fff' }} />
|
||||||
) : voiceState === 'speaking' ? (
|
) : voiceState === 'listening' ? (
|
||||||
<VolumeUpIcon sx={{ color: '#fff', fontSize: 52 }} />
|
<GraphicEqIcon sx={{ color: '#fff', fontSize: 48 }} />
|
||||||
) : voiceState === 'thinking' ? (
|
|
||||||
<SmartToyIcon sx={{ color: '#fff', fontSize: 52 }} />
|
|
||||||
) : (
|
) : (
|
||||||
<MicIcon sx={{ color: '#fff', fontSize: 52 }} />
|
<MicIcon sx={{ color: '#fff', fontSize: 48 }} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
|
||||||
{orbLabel[voiceState]}
|
{orbLabel[voiceState]}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -281,22 +368,21 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<StopIcon />}
|
startIcon={<StopIcon />}
|
||||||
onClick={stopListening}
|
onClick={handleMicToggle}
|
||||||
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00' }}
|
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00', mb: 1 }}
|
||||||
>
|
>
|
||||||
Stop & Process
|
Stop & Process
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language Toggle */}
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1.5 }}>
|
{(['en-IN', 'hi-IN'] as const).map((lang) => (
|
||||||
{['en-IN', 'hi-IN'].map((lang) => (
|
|
||||||
<Chip
|
<Chip
|
||||||
key={lang}
|
key={lang}
|
||||||
label={lang === 'en-IN' ? '🇮🇳 English' : '🇮🇳 हिन्दी'}
|
label={lang === 'en-IN' ? 'English' : 'हिन्दी'}
|
||||||
size="small"
|
size="small"
|
||||||
clickable
|
clickable
|
||||||
onClick={() => setSelectedLang(lang as 'en-IN' | 'hi-IN')}
|
onClick={() => setSelectedLang(lang)}
|
||||||
color={selectedLang === lang ? 'primary' : 'default'}
|
color={selectedLang === lang ? 'primary' : 'default'}
|
||||||
variant={selectedLang === lang ? 'filled' : 'outlined'}
|
variant={selectedLang === lang ? 'filled' : 'outlined'}
|
||||||
sx={{ fontWeight: 700 }}
|
sx={{ fontWeight: 700 }}
|
||||||
|
|
@ -305,8 +391,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Live Transcript */}
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||||
{(transcript || interimTranscript) && (
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder='Type order e.g. "2 butter chicken and garlic naan"'
|
||||||
|
value={typedOrder}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setTypedOrder(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleTypedSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !typedOrder.trim()}
|
||||||
|
onClick={handleTypedSubmit}
|
||||||
|
sx={{ fontWeight: 800, bgcolor: '#ac2d00', px: 2, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{shownTranscript && (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -318,18 +425,29 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
|
||||||
WHAT I HEARD:
|
ORDER TEXT:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
|
||||||
"{transcript}{interimTranscript && <span style={{ color: '#9e9e9e' }}>{interimTranscript}</span>}"
|
"{shownTranscript}"
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detected Items */}
|
{assistantText && (
|
||||||
<Fade in={detectedItems.length > 0}>
|
<Alert severity="success" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
{assistantText}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolCalls.length > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mb: 1, color: '#8f7068', fontWeight: 700 }}>
|
||||||
|
Tools: {toolCalls.join(' → ')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Fade in={cartLines.length > 0}>
|
||||||
<Box>
|
<Box>
|
||||||
{detectedItems.length > 0 && (
|
{cartLines.length > 0 && (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -341,11 +459,11 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
|
||||||
✅ Items Detected:
|
Cart from AI:
|
||||||
</Typography>
|
</Typography>
|
||||||
{detectedItems.map((d) => (
|
{cartLines.map((line) => (
|
||||||
<Box
|
<Box
|
||||||
key={d.menuItem.id}
|
key={`${line.menu_item_id}-${line.quantity}-${line.notes.join(',')}`}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|
@ -356,15 +474,17 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||||
{d.qty}× {d.menuItem.name}
|
{line.quantity}× {line.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#546067' }}>
|
<Typography variant="caption" sx={{ color: '#546067' }}>
|
||||||
₹{(d.qty * d.menuItem.price).toFixed(0)}
|
₹{(Number(line.unit_price) * line.quantity).toFixed(0)}
|
||||||
|
{line.notes?.length ? ` · ${line.notes.join(', ')}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
label={d.menuItem.dietary === 'non-veg' ? '🔴 Non-Veg' : '🟢 Veg'}
|
label={line.is_available ? 'Available' : "86'd"}
|
||||||
size="small"
|
size="small"
|
||||||
|
color={line.is_available ? 'success' : 'warning'}
|
||||||
sx={{ fontWeight: 700 }}
|
sx={{ fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -375,24 +495,27 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => { setDetectedItems([]); resetTranscript(); }}
|
onClick={() => {
|
||||||
|
setCartLines([]);
|
||||||
|
setAssistantText('');
|
||||||
|
setLiveTranscript('');
|
||||||
|
setToolCalls([]);
|
||||||
|
onSyncCart?.([]);
|
||||||
|
}}
|
||||||
sx={{ fontWeight: 700 }}
|
sx={{ fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
Retry
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
startIcon={confirmed ? <CheckCircleIcon /> : <AddShoppingCartIcon />}
|
startIcon={confirmed ? <CheckCircleIcon /> : <SendIcon />}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={confirmed}
|
disabled={confirmed || busy}
|
||||||
sx={{
|
sx={{ fontWeight: 800, bgcolor: confirmed ? '#006a2e' : '#ac2d00' }}
|
||||||
fontWeight: 800,
|
|
||||||
bgcolor: confirmed ? '#006a2e' : '#ac2d00',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{confirmed ? 'Added!' : 'Add to Cart'}
|
{confirmed ? 'Ordered!' : 'Confirm & Place Order'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
@ -400,7 +523,6 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
|
|
||||||
{/* Quick Commands */}
|
|
||||||
<Divider sx={{ my: 2 }}>
|
<Divider sx={{ my: 2 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
||||||
QUICK COMMANDS
|
QUICK COMMANDS
|
||||||
|
|
@ -408,9 +530,9 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{QUICK_COMMANDS.map((cmd, idx) => (
|
{QUICK_COMMANDS.map((cmd) => (
|
||||||
<Paper
|
<Paper
|
||||||
key={idx}
|
key={cmd.label}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
onClick={() => handleQuickCommand(cmd.text)}
|
onClick={() => handleQuickCommand(cmd.text)}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -418,18 +540,20 @@ export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
border: '1px solid #e4beb4',
|
border: '1px solid #e4beb4',
|
||||||
cursor: 'pointer',
|
cursor: busy ? 'wait' : 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
|
opacity: busy ? 0.6 : 1,
|
||||||
|
pointerEvents: busy ? 'none' : 'auto',
|
||||||
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
|
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
|
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
"{cmd.label}"
|
"{cmd.label}"
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<AddShoppingCartIcon sx={{ ml: 'auto', color: '#ac2d00', fontSize: 16 }} />
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { KDSTicketCard } from './KDSTicketCard';
|
||||||
|
|
||||||
interface KDSKanbanProps {
|
interface KDSKanbanProps {
|
||||||
orders: KDSOrder[];
|
orders: KDSOrder[];
|
||||||
|
busyOrderId?: string | null;
|
||||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||||
onToggleItem: (orderId: string, itemId: string) => void;
|
onToggleItem: (orderId: string, itemId: string) => void;
|
||||||
onAddSampleOrder: () => void;
|
onAddSampleOrder: () => void;
|
||||||
|
|
@ -25,6 +26,7 @@ interface KDSKanbanProps {
|
||||||
|
|
||||||
export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
orders,
|
orders,
|
||||||
|
busyOrderId = null,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onToggleItem,
|
onToggleItem,
|
||||||
onAddSampleOrder,
|
onAddSampleOrder,
|
||||||
|
|
@ -50,26 +52,38 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
|
<Box sx={{ width: '100%', maxWidth: '100%' }}>
|
||||||
{/* Top Filter Bar */}
|
{/* Top Filter Bar */}
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: { xs: 1.5, md: 2 },
|
||||||
mb: 3,
|
mb: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 2,
|
gap: 1.5,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
border: '1px solid #e2e2e2',
|
border: '1px solid #e2e2e2',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
|
bgcolor: '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', minWidth: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography
|
||||||
<RestaurantMenuIcon />
|
variant="h6"
|
||||||
KDS Kanban Board
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#ac2d00',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RestaurantMenuIcon fontSize="small" />
|
||||||
|
KDS Board
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<ButtonGroup variant="outlined" size="small">
|
<ButtonGroup variant="outlined" size="small">
|
||||||
|
|
@ -124,19 +138,22 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* 4 Column Kanban Board Grid */}
|
{/* 4 Column Kanban Board Grid */}
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={1.5} alignItems="stretch">
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const colOrders = filteredOrders.filter((o) => o.status === col.status);
|
const colOrders = filteredOrders.filter((o) => o.status === col.status);
|
||||||
return (
|
return (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={col.status}>
|
<Grid size={{ xs: 12, sm: 6, lg: 3 }} key={col.status}>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.25,
|
||||||
minHeight: '75vh',
|
minHeight: { xs: 320, lg: 'calc(100vh - 200px)' },
|
||||||
|
height: '100%',
|
||||||
backgroundColor: '#f6f7f8',
|
backgroundColor: '#f6f7f8',
|
||||||
border: '1px solid #e4beb4',
|
border: '1px solid #e4beb4',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
|
|
@ -145,12 +162,23 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
mb: 2,
|
gap: 1,
|
||||||
|
mb: 1.5,
|
||||||
pb: 1,
|
pb: 1,
|
||||||
borderBottom: '2px solid #e2e2e2',
|
borderBottom: '2px solid #e2e2e2',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: col.color,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: col.color, letterSpacing: '0.05em' }}>
|
|
||||||
{col.title}
|
{col.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
|
|
@ -165,16 +193,16 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Column Tickets */}
|
{/* Column Tickets */}
|
||||||
<Box>
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', minHeight: 0, pr: 0.25 }}>
|
||||||
{colOrders.length === 0 ? (
|
{colOrders.length === 0 ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 4,
|
p: 3,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#8f7068',
|
color: '#8f7068',
|
||||||
border: '2px dashed #e2e2e2',
|
border: '2px dashed #e2e2e2',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
mt: 2,
|
mt: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
|
@ -186,6 +214,7 @@ export const KDSKanban: React.FC<KDSKanbanProps> = ({
|
||||||
<KDSTicketCard
|
<KDSTicketCard
|
||||||
key={order.id}
|
key={order.id}
|
||||||
order={order}
|
order={order}
|
||||||
|
busy={busyOrderId === order.id}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
onToggleItem={onToggleItem}
|
onToggleItem={onToggleItem}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Chip,
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
|
@ -22,16 +23,17 @@ import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
|
||||||
interface KDSTicketCardProps {
|
interface KDSTicketCardProps {
|
||||||
order: KDSOrder;
|
order: KDSOrder;
|
||||||
|
busy?: boolean;
|
||||||
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
onStatusChange: (id: string, newStatus: OrderStatus) => void;
|
||||||
onToggleItem: (orderId: string, itemId: string) => void;
|
onToggleItem: (orderId: string, itemId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
order,
|
order,
|
||||||
|
busy = false,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onToggleItem,
|
onToggleItem,
|
||||||
}) => {
|
}) => {
|
||||||
// Calculate SLA timer color
|
|
||||||
const isUrgent = order.timeElapsedMinutes >= 25 || order.priority === 'urgent';
|
const isUrgent = order.timeElapsedMinutes >= 25 || order.priority === 'urgent';
|
||||||
const isWarning = order.timeElapsedMinutes >= 15 && order.timeElapsedMinutes < 25;
|
const isWarning = order.timeElapsedMinutes >= 15 && order.timeElapsedMinutes < 25;
|
||||||
|
|
||||||
|
|
@ -62,34 +64,56 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
served: 'COMPLETED',
|
served: 'COMPLETED',
|
||||||
}[order.status];
|
}[order.status];
|
||||||
|
|
||||||
const allItemsChecked = order.items.every((i) => i.completed);
|
const allItemsChecked = order.items.length > 0 && order.items.every((i) => i.completed);
|
||||||
|
|
||||||
|
const locationLabel = order.tableNumber
|
||||||
|
? `Table ${order.tableNumber}`
|
||||||
|
: order.customerName ||
|
||||||
|
(order.orderType === 'Takeaway'
|
||||||
|
? 'Takeaway'
|
||||||
|
: order.orderType === 'Delivery'
|
||||||
|
? 'Delivery'
|
||||||
|
: 'Walk-in');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
mb: 2,
|
mb: 1.5,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderLeft: isUrgent ? '5px solid #ba1a1a' : isWarning ? '5px solid #845000' : '5px solid #ac2d00',
|
borderLeft: isUrgent
|
||||||
|
? '5px solid #ba1a1a'
|
||||||
|
: isWarning
|
||||||
|
? '5px solid #845000'
|
||||||
|
: '5px solid #ac2d00',
|
||||||
transition: 'transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
|
transition: 'transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
|
||||||
|
overflow: 'hidden',
|
||||||
|
opacity: busy ? 0.75 : 1,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Ticket Header */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.25,
|
||||||
backgroundColor: '#f3f3f3',
|
backgroundColor: '#f3f3f3',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
|
gap: 1,
|
||||||
borderBottom: '1px solid #e2e2e2',
|
borderBottom: '1px solid #e2e2e2',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0, flex: '1 1 auto' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="h6" sx={{ fontFamily: '"JetBrains Mono", monospace', fontWeight: 800 }}>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '1rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
#{order.ticketNumber}
|
#{order.ticketNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusBadge type={order.orderType} />
|
<StatusBadge type={order.orderType} />
|
||||||
|
|
@ -103,79 +127,102 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#5b4139', display: 'block', mt: 0.2 }}>
|
<Typography
|
||||||
{order.tableNumber ? `Table ${order.tableNumber}` : order.customerName || 'Walk-in Guest'}
|
variant="caption"
|
||||||
{order.serverName && ` • Server: ${order.serverName}`}
|
sx={{
|
||||||
|
color: '#5b4139',
|
||||||
|
display: 'block',
|
||||||
|
mt: 0.5,
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
lineHeight: 1.35,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationLabel}
|
||||||
|
{order.serverName ? ` · ${order.serverName}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* SLA Timer Badge */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.5,
|
gap: 0.4,
|
||||||
px: 1,
|
px: 0.85,
|
||||||
py: 0.4,
|
py: 0.35,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
backgroundColor: timerBg,
|
backgroundColor: timerBg,
|
||||||
color: timerColor,
|
color: timerColor,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AccessTimeIcon fontSize="small" />
|
<AccessTimeIcon sx={{ fontSize: 16 }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.8rem' }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.75rem' }}>
|
||||||
{order.timeElapsedMinutes}m
|
{order.timeElapsedMinutes}m
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Ticket Body / Items */}
|
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
|
||||||
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<Box
|
<Box
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onToggleItem(order.id, item.id)}
|
onClick={() => !busy && onToggleItem(order.id, item.id)}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
p: 0.8,
|
gap: 1,
|
||||||
|
p: 0.75,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
cursor: busy ? 'default' : 'pointer',
|
||||||
backgroundColor: item.completed ? '#f0eded' : 'transparent',
|
backgroundColor: item.completed ? '#f0eded' : 'transparent',
|
||||||
textDecoration: item.completed ? 'line-through' : 'none',
|
opacity: item.completed ? 0.65 : 1,
|
||||||
opacity: item.completed ? 0.6 : 1,
|
'&:hover': busy ? undefined : { backgroundColor: '#f6f3f2' },
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: '#f6f3f2',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, minWidth: 0, flex: 1 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!!item.completed}
|
checked={!!item.completed}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ p: 0, mt: 0.2, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
|
disabled={busy}
|
||||||
|
sx={{ p: 0, mt: 0.15, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: '"JetBrains Mono", monospace',
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
color: '#ac2d00',
|
color: '#ac2d00',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.quantity}x
|
{item.quantity}×
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
textDecoration: item.completed ? 'line-through' : 'none',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
flex: '1 1 80px',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
{item.dietary && <StatusBadge dietary={item.dietary} />}
|
{item.dietary && (
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<StatusBadge dietary={item.dietary} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{item.notes && (
|
{item.notes && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|
@ -183,21 +230,24 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
color: '#ba1a1a',
|
color: '#ba1a1a',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
mt: 0.2,
|
mt: 0.35,
|
||||||
backgroundColor: '#ffdad6',
|
backgroundColor: '#ffdad6',
|
||||||
px: 0.8,
|
px: 0.8,
|
||||||
py: 0.2,
|
py: 0.2,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚠️ {item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
|
<Typography
|
||||||
${(item.price * item.quantity).toFixed(2)}
|
variant="caption"
|
||||||
|
sx={{ color: '#5b4139', fontWeight: 700, flexShrink: 0, pt: 0.2 }}
|
||||||
|
>
|
||||||
|
₹{(item.price * item.quantity).toFixed(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
@ -206,14 +256,22 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Ticket Footer Actions */}
|
|
||||||
<CardActions sx={{ p: 1, justifyContent: 'space-between', backgroundColor: '#fafafa' }}>
|
<CardActions sx={{ p: 1, justifyContent: 'space-between', backgroundColor: '#fafafa' }}>
|
||||||
<Box>
|
<Box>
|
||||||
{prevStatus && (
|
{prevStatus && (
|
||||||
<Tooltip title={`Revert to ${prevStatus}`}>
|
<Tooltip title={`Move back to ${prevStatus}`}>
|
||||||
<IconButton size="small" onClick={() => onStatusChange(order.id, prevStatus)}>
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onStatusChange(order.id, prevStatus);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ArrowBackIcon fontSize="small" />
|
<ArrowBackIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -222,12 +280,18 @@ export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
disabled={busy}
|
||||||
color={allItemsChecked ? 'success' : order.status === 'pending' ? 'primary' : 'warning'}
|
color={allItemsChecked ? 'success' : order.status === 'pending' ? 'primary' : 'warning'}
|
||||||
onClick={() => onStatusChange(order.id, nextStatus)}
|
onClick={(e) => {
|
||||||
endIcon={<ArrowForwardIcon fontSize="small" />}
|
e.stopPropagation();
|
||||||
|
onStatusChange(order.id, nextStatus);
|
||||||
|
}}
|
||||||
|
endIcon={
|
||||||
|
busy ? <CircularProgress size={14} color="inherit" /> : <ArrowForwardIcon fontSize="small" />
|
||||||
|
}
|
||||||
sx={{ fontWeight: 800, fontSize: '0.75rem' }}
|
sx={{ fontWeight: 800, fontSize: '0.75rem' }}
|
||||||
>
|
>
|
||||||
{actionButtonText}
|
{busy ? 'Updating…' : actionButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Chip
|
<Chip
|
||||||
|
|
|
||||||
|
|
@ -1,210 +1,359 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Grid,
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Divider,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Paper,
|
|
||||||
Divider,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import MicIcon from '@mui/icons-material/Mic';
|
import MicIcon from '@mui/icons-material/Mic';
|
||||||
|
import StopIcon from '@mui/icons-material/Stop';
|
||||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||||
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
|
import InventoryIcon from '@mui/icons-material/Inventory';
|
||||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||||
import type { VoiceCommandSuggestion } from '../../types';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
interface VoiceOrderingViewProps {
|
type LowStockRow = {
|
||||||
onAddOrderFromVoice: (transcript: string) => void;
|
id: number;
|
||||||
}
|
name: string;
|
||||||
|
unit: string;
|
||||||
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = ({ onAddOrderFromVoice }) => {
|
current_stock: number;
|
||||||
const [isListening, setIsListening] = useState(false);
|
reorder_threshold: number;
|
||||||
const [transcript, setTranscript] = useState(
|
is_low: boolean;
|
||||||
'Do Butter Chicken, teen Garlic Naan aur ek Mango Lassi Table 12 ke liye'
|
|
||||||
);
|
|
||||||
const [detectedItems] = useState([
|
|
||||||
{ name: 'Butter Chicken', qty: 2, price: 360, dietary: 'non-veg' as const },
|
|
||||||
{ name: 'Garlic Naan', qty: 3, price: 80, dietary: 'veg' as const },
|
|
||||||
{ name: 'Mango Lassi', qty: 1, price: 120, dietary: 'veg' as const },
|
|
||||||
]);
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const samplePrompts: VoiceCommandSuggestion[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
label: '2 Butter Chicken & Garlic Naan – Table 12',
|
|
||||||
prompt: 'Do Butter Chicken aur do Garlic Naan Table 12 ke liye',
|
|
||||||
category: 'order',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
label: 'Hyderabadi Biryani + Raita – Table 8',
|
|
||||||
prompt: 'Ek Hyderabadi Chicken Biryani with extra raita for Table 8',
|
|
||||||
category: 'order',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
label: 'Jain Palak Paneer – no onion no garlic',
|
|
||||||
prompt: 'Add 1 Palak Paneer Jain preparation no onion no garlic for Table 4',
|
|
||||||
category: 'order',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
label: 'Extra Cutlery + Tissue for Table 3',
|
|
||||||
prompt: 'Please send extra cutlery and tissue paper to Table 3',
|
|
||||||
category: 'action',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleConfirmOrder = () => {
|
|
||||||
onAddOrderFromVoice(transcript);
|
|
||||||
setSubmitted(true);
|
|
||||||
setTimeout(() => setSubmitted(false), 3000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = detectedItems.reduce((sum, i) => sum + i.price * i.qty, 0);
|
interface VoiceOrderingViewProps {
|
||||||
|
onAddOrderFromVoice?: (transcript: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_PROMPTS = [
|
||||||
|
{ label: 'What is low stock?', text: 'What ingredients are low on stock right now?' },
|
||||||
|
{ label: 'Add 2kg tomato', text: 'please add 2 kgs tomato in inventory' },
|
||||||
|
{ label: 'Chicken stock?', text: 'How much chicken do we have left?' },
|
||||||
|
{ label: 'Restock cream 1L', text: 'add 1 litre cream to inventory' },
|
||||||
|
{ label: 'Send alert now', text: 'Send a low stock alert notification now' },
|
||||||
|
{ label: 'Full inventory', text: 'Give me a quick inventory stock overview' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = () => {
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const [liveTranscript, setLiveTranscript] = useState('');
|
||||||
|
const [assistantText, setAssistantText] = useState('');
|
||||||
|
const [toolCalls, setToolCalls] = useState<string[]>([]);
|
||||||
|
const [lowStock, setLowStock] = useState<LowStockRow[]>([]);
|
||||||
|
const [updates, setUpdates] = useState<LowStockRow[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
|
const runStaffAi = useCallback(async (text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || busyRef.current) return;
|
||||||
|
busyRef.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setLiveTranscript(trimmed);
|
||||||
|
setAssistantText('');
|
||||||
|
try {
|
||||||
|
const language = selectedLang.startsWith('hi') || /[\u0900-\u097F]/.test(trimmed) ? 'hi' : 'en';
|
||||||
|
const result = await api.staffAiChatTurn({ transcript: trimmed, language });
|
||||||
|
setAssistantText(result.assistant_text || '');
|
||||||
|
setToolCalls(result.tool_calls || []);
|
||||||
|
setLowStock(result.low_stock || []);
|
||||||
|
setUpdates(result.updates || []);
|
||||||
|
setLiveTranscript(result.transcript || trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Staff AI failed');
|
||||||
|
} finally {
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [selectedLang]);
|
||||||
|
|
||||||
|
const runStaffAiRef = useRef(runStaffAi);
|
||||||
|
useEffect(() => {
|
||||||
|
runStaffAiRef.current = runStaffAi;
|
||||||
|
}, [runStaffAi]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
voiceState,
|
||||||
|
transcript,
|
||||||
|
interimTranscript,
|
||||||
|
isSupported,
|
||||||
|
startListening,
|
||||||
|
stopListening,
|
||||||
|
resetTranscript,
|
||||||
|
error: sttError,
|
||||||
|
} = useSpeechRecognition((finalText) => {
|
||||||
|
void runStaffAiRef.current(finalText);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prefetch low stock so the panel isn't empty on first open.
|
||||||
|
void api
|
||||||
|
.getLowStockInventory()
|
||||||
|
.then((rows) => {
|
||||||
|
const list = Array.isArray(rows) ? rows : rows?.items || [];
|
||||||
|
setLowStock(
|
||||||
|
list.map((r: any) => ({
|
||||||
|
id: Number(r.id),
|
||||||
|
name: String(r.name),
|
||||||
|
unit: String(r.unit || ''),
|
||||||
|
current_stock: Number(r.current_stock ?? r.stock ?? 0),
|
||||||
|
reorder_threshold: Number(r.reorder_threshold ?? r.minStock ?? 0),
|
||||||
|
is_low: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore prefetch errors */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMic = () => {
|
||||||
|
if (voiceState === 'listening') {
|
||||||
|
stopListening();
|
||||||
|
const spoken = (transcript || interimTranscript || '').trim();
|
||||||
|
if (spoken) void runStaffAi(spoken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetTranscript();
|
||||||
|
setLiveTranscript('');
|
||||||
|
startListening(selectedLang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shownTranscript = liveTranscript || transcript || interimTranscript;
|
||||||
|
const listening = voiceState === 'listening';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}>
|
<Box sx={{ p: { xs: 2, md: 3 }, maxWidth: 1100, mx: 'auto' }}>
|
||||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
|
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
|
||||||
Staff Voice Assistant Terminal
|
Staff AI Assistant
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ color: '#5b4139' }}>
|
<Typography variant="body1" sx={{ color: '#5b4139' }}>
|
||||||
Hands-free voice intake for servers & kitchen staff. Supports Hindi, English & Hinglish.
|
Ask about inventory by voice or text — get low-stock checks and send alerts anytime.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Voice Orb */}
|
<Grid container spacing={2.5}>
|
||||||
|
<Grid size={{ xs: 12, md: 5 }}>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
p: 4,
|
p: 3,
|
||||||
mb: 4,
|
borderRadius: '20px',
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: '24px',
|
|
||||||
background: 'linear-gradient(180deg, #ffffff 0%, #ffdbd1 100%)',
|
|
||||||
border: '1px solid #e4beb4',
|
border: '1px solid #e4beb4',
|
||||||
|
bgcolor: '#fff9f7',
|
||||||
|
textAlign: 'center',
|
||||||
|
minHeight: 360,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => setIsListening(!isListening)}
|
onClick={toggleMic}
|
||||||
sx={{
|
sx={{
|
||||||
width: 130,
|
width: 110,
|
||||||
height: 130,
|
height: 110,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
mx: 'auto',
|
display: 'grid',
|
||||||
mb: 3,
|
placeItems: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isListening
|
color: '#fff',
|
||||||
? 'radial-gradient(circle, #d53e0b 0%, #ac2d00 70%)'
|
background: listening
|
||||||
: 'radial-gradient(circle, #ac2d00 0%, #872100 100%)',
|
? 'linear-gradient(135deg, #d53e0b, #e75a2b)'
|
||||||
boxShadow: isListening
|
: busy
|
||||||
? '0 0 0 18px rgba(213, 62, 11, 0.2), 0 0 0 36px rgba(172, 45, 0, 0.1)'
|
? 'linear-gradient(135deg, #845000, #b07000)'
|
||||||
: '0 4px 20px rgba(172, 45, 0, 0.3)',
|
: 'linear-gradient(135deg, #ac2d00, #d53e0b)',
|
||||||
display: 'flex',
|
boxShadow: listening
|
||||||
alignItems: 'center',
|
? '0 0 0 14px rgba(213,62,11,0.18)'
|
||||||
justifyContent: 'center',
|
: '0 8px 24px rgba(172,45,0,0.35)',
|
||||||
color: '#ffffff',
|
|
||||||
transition: 'all 0.3s ease-in-out',
|
|
||||||
transform: isListening ? 'scale(1.08)' : 'scale(1)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isListening ? <GraphicEqIcon sx={{ fontSize: 60 }} /> : <MicIcon sx={{ fontSize: 60 }} />}
|
{busy ? <CircularProgress size={36} color="inherit" /> : listening ? <StopIcon sx={{ fontSize: 40 }} /> : <MicIcon sx={{ fontSize: 40 }} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#ac2d00' }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
|
{busy ? 'AI checking inventory…' : listening ? 'Listening… tap to stop' : isSupported ? 'Tap to speak' : 'Type a question below'}
|
||||||
{isListening ? '🎙️ Listening & Parsing Voice Input...' : 'Tap Orb or Speak to Begin Order'}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600, display: 'block', mb: 3 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
Multilingual: English · Hindi · Hinglish
|
{(['en-IN', 'hi-IN'] as const).map((lang) => (
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Live Transcript */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 2.5, maxWidth: 700, mx: 'auto', bgcolor: '#ffffff', borderRadius: '12px', border: '2px solid #ac2d00', textAlign: 'left' }}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
|
|
||||||
LIVE TRANSCRIPT:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
|
|
||||||
"{transcript}"
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Quick Prompts */}
|
|
||||||
<Box sx={{ mb: 4 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#546067', mb: 1.5, textTransform: 'uppercase' }}>
|
|
||||||
Suggested Quick Voice Commands
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={1.5}>
|
|
||||||
{samplePrompts.map((s) => (
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }} key={s.id}>
|
|
||||||
<Chip
|
<Chip
|
||||||
icon={<SmartToyIcon fontSize="small" />}
|
key={lang}
|
||||||
label={s.label}
|
label={lang === 'en-IN' ? 'English' : 'Hindi'}
|
||||||
onClick={() => { setTranscript(s.prompt); setIsListening(true); setTimeout(() => setIsListening(false), 1500); }}
|
size="small"
|
||||||
clickable
|
onClick={() => setSelectedLang(lang)}
|
||||||
sx={{ width: '100%', justifyContent: 'flex-start', py: 2.5, px: 1, fontWeight: 700, bgcolor: '#ffffff', border: '1px solid #e4beb4', '&:hover': { bgcolor: '#ffdbd1' } }}
|
color={selectedLang === lang ? 'primary' : 'default'}
|
||||||
|
sx={{ fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{(error || sttError) && (
|
||||||
{/* Detected Items */}
|
<Alert severity="error" sx={{ width: '100%', textAlign: 'left', fontWeight: 600 }}>
|
||||||
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}>
|
{error || sttError}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
</Alert>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>AI Extracted Items</Typography>
|
)}
|
||||||
<Chip label="Table 12" color="primary" sx={{ fontWeight: 800 }} />
|
{shownTranscript && (
|
||||||
</Box>
|
<Paper elevation={0} sx={{ p: 1.5, width: '100%', bgcolor: '#fff', border: '1px solid #f0ebe7', borderRadius: 2 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, color: '#8f7068' }}>
|
||||||
<List disablePadding>
|
You said
|
||||||
{detectedItems.map((item, idx) => (
|
|
||||||
<ListItem
|
|
||||||
key={idx}
|
|
||||||
sx={{ py: 1.5, px: 2, mb: 1, borderRadius: '8px', bgcolor: '#f6f7f8', display: 'flex', justifyContent: 'space-between' }}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ color: '#ac2d00', fontWeight: 800, fontFamily: 'monospace' }}>
|
|
||||||
{item.qty}×
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<ListItemText
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{shownTranscript}</Typography>
|
||||||
primary={<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>}
|
</Paper>
|
||||||
secondary={`Subtotal: ₹${(item.qty * item.price)}`}
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 7 }}>
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff', mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<SmartToyIcon sx={{ color: '#ac2d00' }} />
|
||||||
|
<Typography sx={{ fontWeight: 800 }}>AI reply</Typography>
|
||||||
|
</Box>
|
||||||
|
{assistantText ? (
|
||||||
|
<Alert
|
||||||
|
severity={updates.length ? 'info' : 'success'}
|
||||||
|
icon={<GraphicEqIcon />}
|
||||||
|
sx={{ fontWeight: 600, mb: 1.5 }}
|
||||||
|
>
|
||||||
|
{assistantText}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: '#8f7068', mb: 1.5 }}>
|
||||||
|
Try: “Add 2 kg tomato” or “What is low stock?”
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{updates.length > 0 && (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
{updates.map((row) => (
|
||||||
|
<Chip
|
||||||
|
key={`upd-${row.id}`}
|
||||||
|
color="success"
|
||||||
|
sx={{ fontWeight: 800, mr: 1, mb: 1 }}
|
||||||
|
label={`Updated ${row.name}: ${row.current_stock}${row.unit}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{toolCalls.length > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 700 }}>
|
||||||
|
Tools: {toolCalls.join(' → ')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Type: add 2 kg tomato / check cream stock…"
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && typed.trim()) {
|
||||||
|
void runStaffAi(typed);
|
||||||
|
setTyped('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: '12px' } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !typed.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
void runStaffAi(typed);
|
||||||
|
setTyped('');
|
||||||
|
}}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ fontWeight: 800, bgcolor: '#ac2d00', borderRadius: '12px', px: 2 }}
|
||||||
|
>
|
||||||
|
Ask
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{QUICK_PROMPTS.map((p) => (
|
||||||
|
<Chip
|
||||||
|
key={p.label}
|
||||||
|
label={p.label}
|
||||||
|
onClick={() => void runStaffAi(p.text)}
|
||||||
|
sx={{ fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, borderRadius: '20px', border: '1px solid #f0ebe7', bgcolor: '#fff' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, gap: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<InventoryIcon sx={{ color: '#ac2d00' }} />
|
||||||
|
<Typography sx={{ fontWeight: 800 }}>Low stock now</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={lowStock.length}
|
||||||
|
color={lowStock.length ? 'error' : 'success'}
|
||||||
|
sx={{ fontWeight: 800 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip label={item.dietary.toUpperCase()} size="small" variant="outlined" sx={{ fontWeight: 700 }} />
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<NotificationsActiveIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void runStaffAi('Send a low stock alert notification now')}
|
||||||
|
sx={{ fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
||||||
|
>
|
||||||
|
Notify
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{lowStock.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: '#8f7068' }}>
|
||||||
|
No ingredients below reorder threshold.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding>
|
||||||
|
{lowStock.map((row) => (
|
||||||
|
<ListItem
|
||||||
|
key={row.id}
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
|
mb: 0.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#fff5f2',
|
||||||
|
border: '1px solid #ffdad6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem' }}>{row.name}</Typography>
|
||||||
|
}
|
||||||
|
secondary={`Now ${row.current_stock}${row.unit} · reorder at ${row.reorder_threshold}${row.unit}`}
|
||||||
|
/>
|
||||||
|
<Chip label="LOW" size="small" color="error" sx={{ fontWeight: 800 }} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
)}
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Total: ₹{totalValue}</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
size="large"
|
|
||||||
disabled={submitted}
|
|
||||||
startIcon={submitted ? <CheckCircleOutlinedIcon /> : <AddShoppingCartIcon />}
|
|
||||||
onClick={handleConfirmOrder}
|
|
||||||
sx={{ fontWeight: 800, px: 4 }}
|
|
||||||
>
|
|
||||||
{submitted ? 'ORDER SENT TO KDS!' : 'DISPATCH TO KDS'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
nameHindi?: string;
|
nameHindi?: string;
|
||||||
category: 'Starters' | 'Biryani' | 'Mains' | 'Breads' | 'Drinks' | 'Desserts' | 'Specials';
|
category: 'Starters' | 'Biryani' | 'Mains' | 'Breads' | 'Drinks' | 'Desserts' | 'Specials';
|
||||||
|
|
@ -12,12 +12,14 @@ export interface MenuItem {
|
||||||
image: string;
|
image: string;
|
||||||
spiceLevel?: 'mild' | 'medium' | 'hot' | 'extra-hot';
|
spiceLevel?: 'mild' | 'medium' | 'hot' | 'extra-hot';
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** Present when loaded from API; static fallback treats missing as available */
|
||||||
|
isAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MENU_ITEMS: MenuItem[] = [
|
export const MENU_ITEMS: MenuItem[] = [
|
||||||
// ─── STARTERS ───────────────────────────────────────────────────
|
// ─── STARTERS ───────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm1',
|
id: 1,
|
||||||
name: 'Samosa Chaat',
|
name: 'Samosa Chaat',
|
||||||
nameHindi: 'समोसा चाट',
|
nameHindi: 'समोसा चाट',
|
||||||
category: 'Starters',
|
category: 'Starters',
|
||||||
|
|
@ -31,7 +33,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Popular', 'Street Food'],
|
tags: ['Popular', 'Street Food'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm2',
|
id: 2,
|
||||||
name: 'Paneer Tikka',
|
name: 'Paneer Tikka',
|
||||||
nameHindi: 'पनीर टिक्का',
|
nameHindi: 'पनीर टिक्का',
|
||||||
category: 'Starters',
|
category: 'Starters',
|
||||||
|
|
@ -45,7 +47,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ["Chef's Special", 'Tandoor'],
|
tags: ["Chef's Special", 'Tandoor'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm3',
|
id: 3,
|
||||||
name: 'Chicken 65',
|
name: 'Chicken 65',
|
||||||
nameHindi: 'चिकन 65',
|
nameHindi: 'चिकन 65',
|
||||||
category: 'Starters',
|
category: 'Starters',
|
||||||
|
|
@ -59,7 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['South Indian', 'Crispy'],
|
tags: ['South Indian', 'Crispy'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm4',
|
id: 4,
|
||||||
name: 'Hara Bhara Kabab',
|
name: 'Hara Bhara Kabab',
|
||||||
nameHindi: 'हरा भरा कबाब',
|
nameHindi: 'हरा भरा कबाब',
|
||||||
category: 'Starters',
|
category: 'Starters',
|
||||||
|
|
@ -72,7 +74,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Healthy', 'Spinach'],
|
tags: ['Healthy', 'Spinach'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm5',
|
id: 5,
|
||||||
name: 'Seekh Kebab',
|
name: 'Seekh Kebab',
|
||||||
nameHindi: 'सीख कबाब',
|
nameHindi: 'सीख कबाब',
|
||||||
category: 'Starters',
|
category: 'Starters',
|
||||||
|
|
@ -88,7 +90,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── BIRYANI ────────────────────────────────────────────────────
|
// ─── BIRYANI ────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm6',
|
id: 6,
|
||||||
name: 'Hyderabadi Chicken Biryani',
|
name: 'Hyderabadi Chicken Biryani',
|
||||||
nameHindi: 'हैदराबादी चिकन बिरयानी',
|
nameHindi: 'हैदराबादी चिकन बिरयानी',
|
||||||
category: 'Biryani',
|
category: 'Biryani',
|
||||||
|
|
@ -103,7 +105,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Dum Style', 'Hyderabadi', 'Bestseller'],
|
tags: ['Dum Style', 'Hyderabadi', 'Bestseller'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm7',
|
id: 7,
|
||||||
name: 'Veg Dum Biryani',
|
name: 'Veg Dum Biryani',
|
||||||
nameHindi: 'वेज दम बिरयानी',
|
nameHindi: 'वेज दम बिरयानी',
|
||||||
category: 'Biryani',
|
category: 'Biryani',
|
||||||
|
|
@ -116,7 +118,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Dum Style', 'Veg'],
|
tags: ['Dum Style', 'Veg'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm8',
|
id: 8,
|
||||||
name: 'Mutton Biryani',
|
name: 'Mutton Biryani',
|
||||||
nameHindi: 'मटन बिरयानी',
|
nameHindi: 'मटन बिरयानी',
|
||||||
category: 'Biryani',
|
category: 'Biryani',
|
||||||
|
|
@ -131,7 +133,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── MAINS ──────────────────────────────────────────────────────
|
// ─── MAINS ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm9',
|
id: 9,
|
||||||
name: 'Butter Chicken',
|
name: 'Butter Chicken',
|
||||||
nameHindi: 'बटर चिकन',
|
nameHindi: 'बटर चिकन',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -145,7 +147,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Classic', 'Bestseller'],
|
tags: ['Classic', 'Bestseller'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm10',
|
id: 10,
|
||||||
name: 'Dal Makhani',
|
name: 'Dal Makhani',
|
||||||
nameHindi: 'दाल मखनी',
|
nameHindi: 'दाल मखनी',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -159,7 +161,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Punjabi', 'Classic'],
|
tags: ['Punjabi', 'Classic'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm11',
|
id: 11,
|
||||||
name: 'Palak Paneer',
|
name: 'Palak Paneer',
|
||||||
nameHindi: 'पालक पनीर',
|
nameHindi: 'पालक पनीर',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -172,7 +174,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Healthy', 'Jain Available'],
|
tags: ['Healthy', 'Jain Available'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm12',
|
id: 12,
|
||||||
name: 'Chicken Kadhai',
|
name: 'Chicken Kadhai',
|
||||||
nameHindi: 'चिकन कड़ाही',
|
nameHindi: 'चिकन कड़ाही',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -186,7 +188,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Kadhai Style', "Chef's Special"],
|
tags: ['Kadhai Style', "Chef's Special"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm13',
|
id: 13,
|
||||||
name: 'Paneer Butter Masala',
|
name: 'Paneer Butter Masala',
|
||||||
nameHindi: 'पनीर बटर मसाला',
|
nameHindi: 'पनीर बटर मसाला',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -199,7 +201,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Popular'],
|
tags: ['Popular'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm14',
|
id: 14,
|
||||||
name: 'Rogan Josh',
|
name: 'Rogan Josh',
|
||||||
nameHindi: 'रोगन जोश',
|
nameHindi: 'रोगन जोश',
|
||||||
category: 'Mains',
|
category: 'Mains',
|
||||||
|
|
@ -214,7 +216,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── BREADS ──────────────────────────────────────────────────────
|
// ─── BREADS ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm15',
|
id: 15,
|
||||||
name: 'Butter Naan',
|
name: 'Butter Naan',
|
||||||
nameHindi: 'बटर नान',
|
nameHindi: 'बटर नान',
|
||||||
category: 'Breads',
|
category: 'Breads',
|
||||||
|
|
@ -226,7 +228,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Tandoor'],
|
tags: ['Tandoor'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm16',
|
id: 16,
|
||||||
name: 'Garlic Naan',
|
name: 'Garlic Naan',
|
||||||
nameHindi: 'लहसुन नान',
|
nameHindi: 'लहसुन नान',
|
||||||
category: 'Breads',
|
category: 'Breads',
|
||||||
|
|
@ -239,7 +241,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Bestseller', 'Tandoor'],
|
tags: ['Bestseller', 'Tandoor'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm17',
|
id: 17,
|
||||||
name: 'Lachha Paratha',
|
name: 'Lachha Paratha',
|
||||||
nameHindi: 'लच्छा पराठा',
|
nameHindi: 'लच्छा पराठा',
|
||||||
category: 'Breads',
|
category: 'Breads',
|
||||||
|
|
@ -253,7 +255,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── DRINKS ──────────────────────────────────────────────────────
|
// ─── DRINKS ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm18',
|
id: 18,
|
||||||
name: 'Mango Lassi',
|
name: 'Mango Lassi',
|
||||||
nameHindi: 'आम की लस्सी',
|
nameHindi: 'आम की लस्सी',
|
||||||
category: 'Drinks',
|
category: 'Drinks',
|
||||||
|
|
@ -266,7 +268,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Summer Special', 'Fresh'],
|
tags: ['Summer Special', 'Fresh'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm19',
|
id: 19,
|
||||||
name: 'Masala Chai',
|
name: 'Masala Chai',
|
||||||
nameHindi: 'मसाला चाय',
|
nameHindi: 'मसाला चाय',
|
||||||
category: 'Drinks',
|
category: 'Drinks',
|
||||||
|
|
@ -278,7 +280,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Kulhad', 'Hot'],
|
tags: ['Kulhad', 'Hot'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm20',
|
id: 20,
|
||||||
name: 'Sweet Lime Soda',
|
name: 'Sweet Lime Soda',
|
||||||
nameHindi: 'नींबू सोडा',
|
nameHindi: 'नींबू सोडा',
|
||||||
category: 'Drinks',
|
category: 'Drinks',
|
||||||
|
|
@ -290,7 +292,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Fresh', 'Refreshing'],
|
tags: ['Fresh', 'Refreshing'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm21',
|
id: 21,
|
||||||
name: 'Rose Sharbat',
|
name: 'Rose Sharbat',
|
||||||
nameHindi: 'गुलाब शरबत',
|
nameHindi: 'गुलाब शरबत',
|
||||||
category: 'Drinks',
|
category: 'Drinks',
|
||||||
|
|
@ -304,7 +306,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── DESSERTS ────────────────────────────────────────────────────
|
// ─── DESSERTS ────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm22',
|
id: 22,
|
||||||
name: 'Gulab Jamun',
|
name: 'Gulab Jamun',
|
||||||
nameHindi: 'गुलाब जामुन',
|
nameHindi: 'गुलाब जामुन',
|
||||||
category: 'Desserts',
|
category: 'Desserts',
|
||||||
|
|
@ -317,7 +319,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Hot', 'Classic'],
|
tags: ['Hot', 'Classic'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm23',
|
id: 23,
|
||||||
name: 'Rasmalai',
|
name: 'Rasmalai',
|
||||||
nameHindi: 'रसमलाई',
|
nameHindi: 'रसमलाई',
|
||||||
category: 'Desserts',
|
category: 'Desserts',
|
||||||
|
|
@ -330,7 +332,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Bengali', "Chef's Special", 'Cold'],
|
tags: ['Bengali', "Chef's Special", 'Cold'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm24',
|
id: 24,
|
||||||
name: 'Gajar Halwa',
|
name: 'Gajar Halwa',
|
||||||
nameHindi: 'गाजर का हलवा',
|
nameHindi: 'गाजर का हलवा',
|
||||||
category: 'Desserts',
|
category: 'Desserts',
|
||||||
|
|
@ -344,7 +346,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
|
||||||
// ─── SPECIALS ─────────────────────────────────────────────────────
|
// ─── SPECIALS ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'm25',
|
id: 25,
|
||||||
name: 'Chef\'s Thali',
|
name: 'Chef\'s Thali',
|
||||||
nameHindi: 'शेफ की थाली',
|
nameHindi: 'शेफ की थाली',
|
||||||
category: 'Specials',
|
category: 'Specials',
|
||||||
|
|
@ -358,7 +360,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||||
tags: ['Thali', 'Value', 'Complete Meal'],
|
tags: ['Thali', 'Value', 'Complete Meal'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm26',
|
id: 26,
|
||||||
name: 'Non-Veg Thali',
|
name: 'Non-Veg Thali',
|
||||||
nameHindi: 'नॉन-वेज थाली',
|
nameHindi: 'नॉन-वेज थाली',
|
||||||
category: 'Specials',
|
category: 'Specials',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { debugLog } from '../utils/debugLog';
|
||||||
|
|
||||||
interface SpeechRecognitionEvent extends Event {
|
interface SpeechRecognitionEvent extends Event {
|
||||||
results: SpeechRecognitionResultList;
|
results: SpeechRecognitionResultList;
|
||||||
|
|
@ -43,12 +44,28 @@ interface UseSpeechRecognitionReturn {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
|
type FinalHandler = (text: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome Web Speech (webkitSpeechRecognition).
|
||||||
|
* Submits final text on end; if Chrome only left interim results, submits those too.
|
||||||
|
*/
|
||||||
|
export const useSpeechRecognition = (
|
||||||
|
onFinalTranscript?: FinalHandler
|
||||||
|
): UseSpeechRecognitionReturn => {
|
||||||
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
|
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
|
||||||
const [transcript, setTranscript] = useState<string>('');
|
const [transcript, setTranscript] = useState('');
|
||||||
const [interimTranscript, setInterimTranscript] = useState<string>('');
|
const [interimTranscript, setInterimTranscript] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
||||||
|
const finalBufferRef = useRef('');
|
||||||
|
const interimRef = useRef('');
|
||||||
|
const submittedRef = useRef(false);
|
||||||
|
const onFinalRef = useRef<FinalHandler | undefined>(onFinalTranscript);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFinalRef.current = onFinalTranscript;
|
||||||
|
}, [onFinalTranscript]);
|
||||||
|
|
||||||
const isSupported =
|
const isSupported =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
|
|
@ -56,84 +73,160 @@ export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
try {
|
||||||
recognitionRef.current?.abort();
|
recognitionRef.current?.abort();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startListening = useCallback((lang: string = 'en-IN') => {
|
const emitFinal = useCallback(() => {
|
||||||
|
if (submittedRef.current) return;
|
||||||
|
const text = (finalBufferRef.current || interimRef.current).trim();
|
||||||
|
if (!text) {
|
||||||
|
debugLog.warn('stt', 'emitFinal skipped — empty buffer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submittedRef.current = true;
|
||||||
|
setTranscript(text);
|
||||||
|
setInterimTranscript('');
|
||||||
|
debugLog.info('stt', 'emitFinal', {
|
||||||
|
source: finalBufferRef.current.trim() ? 'final' : 'interim-fallback',
|
||||||
|
text: text.slice(0, 160),
|
||||||
|
});
|
||||||
|
onFinalRef.current?.(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startListening = useCallback(
|
||||||
|
(lang: string = 'en-IN') => {
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
setError('Voice recognition is not supported in this browser. Please use Chrome.');
|
setError('Voice recognition is not supported in this browser.');
|
||||||
setVoiceState('error');
|
setVoiceState('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recognitionRef.current?.abort();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
finalBufferRef.current = '';
|
||||||
|
interimRef.current = '';
|
||||||
|
submittedRef.current = false;
|
||||||
|
setTranscript('');
|
||||||
|
setInterimTranscript('');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
const recognition = new SpeechRecognitionAPI();
|
const recognition = new SpeechRecognitionAPI();
|
||||||
recognitionRef.current = recognition;
|
recognitionRef.current = recognition;
|
||||||
|
|
||||||
recognition.lang = lang;
|
// continuous=true: user taps stop; Chrome is more reliable this way than auto-end.
|
||||||
recognition.continuous = false;
|
recognition.lang = lang === 'hi-IN' ? 'hi-IN' : 'en-IN';
|
||||||
|
recognition.continuous = true;
|
||||||
recognition.interimResults = true;
|
recognition.interimResults = true;
|
||||||
recognition.maxAlternatives = 1;
|
recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
recognition.onstart = () => {
|
recognition.onstart = () => {
|
||||||
|
debugLog.info('stt', 'recognition onstart', { lang: recognition.lang });
|
||||||
setVoiceState('listening');
|
setVoiceState('listening');
|
||||||
setError(null);
|
setError(null);
|
||||||
setInterimTranscript('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
let interim = '';
|
let interim = '';
|
||||||
let final = '';
|
let finals = finalBufferRef.current;
|
||||||
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
const result = event.results[i];
|
const result = event.results[i];
|
||||||
|
const piece = result[0]?.transcript || '';
|
||||||
if (result.isFinal) {
|
if (result.isFinal) {
|
||||||
final += result[0].transcript;
|
finals = `${finals} ${piece}`.trim();
|
||||||
} else {
|
} else {
|
||||||
interim += result[0].transcript;
|
interim += piece;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalBufferRef.current = finals;
|
||||||
|
interimRef.current = interim;
|
||||||
|
setTranscript(finals);
|
||||||
setInterimTranscript(interim);
|
setInterimTranscript(interim);
|
||||||
if (final) {
|
debugLog.info('stt', 'recognition onresult', {
|
||||||
setTranscript((prev) => prev + ' ' + final.trim());
|
final: finals.slice(0, 120),
|
||||||
setVoiceState('thinking');
|
interim: interim.slice(0, 120),
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||||
|
// Chrome fires "aborted" when we restart/stop — not a real failure.
|
||||||
|
if (event.error === 'aborted' || event.error === 'no-speech') {
|
||||||
|
debugLog.info('stt', 'recognition soft-error ignored', { error: event.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugLog.error('stt', 'recognition onerror', { error: event.error });
|
||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
'no-speech': 'No speech detected. Please try again.',
|
'audio-capture': 'Microphone not accessible. Check Chrome site permissions.',
|
||||||
'audio-capture': 'Microphone not accessible.',
|
'not-allowed': 'Microphone blocked. Allow mic for localhost in Chrome.',
|
||||||
'not-allowed': 'Microphone permission denied. Please allow access.',
|
network:
|
||||||
'network': 'Network error. Check your internet connection.',
|
'Chrome speech service network error. Use the text box or a quick command.',
|
||||||
|
'service-not-allowed': 'Chrome speech service blocked. Use text / quick command.',
|
||||||
};
|
};
|
||||||
setError(errorMessages[event.error] || `Voice error: ${event.error}`);
|
setError(errorMessages[event.error] || `Voice error: ${event.error}`);
|
||||||
setVoiceState('error');
|
setVoiceState('error');
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onend = () => {
|
recognition.onend = () => {
|
||||||
setInterimTranscript('');
|
debugLog.info('stt', 'recognition onend', {
|
||||||
if (voiceState === 'listening') {
|
finalBuf: finalBufferRef.current.slice(0, 120),
|
||||||
setVoiceState('thinking');
|
interimBuf: interimRef.current.slice(0, 120),
|
||||||
}
|
submitted: submittedRef.current,
|
||||||
|
});
|
||||||
|
setVoiceState('idle');
|
||||||
|
emitFinal();
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recognition.start();
|
recognition.start();
|
||||||
} catch (e) {
|
debugLog.info('stt', 'recognition.start() called', { lang: recognition.lang, continuous: true });
|
||||||
setError('Could not start voice recognition.');
|
} catch (err) {
|
||||||
|
debugLog.error('stt', 'recognition.start() threw', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
setError('Could not start Chrome speech recognition. Try the text box.');
|
||||||
setVoiceState('error');
|
setVoiceState('error');
|
||||||
}
|
}
|
||||||
}, [isSupported, voiceState]);
|
},
|
||||||
|
[emitFinal, isSupported]
|
||||||
|
);
|
||||||
|
|
||||||
const stopListening = useCallback(() => {
|
const stopListening = useCallback(() => {
|
||||||
recognitionRef.current?.stop();
|
const recognition = recognitionRef.current;
|
||||||
setVoiceState('thinking');
|
if (!recognition) {
|
||||||
}, []);
|
emitFinal();
|
||||||
|
setVoiceState('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch {
|
||||||
|
emitFinal();
|
||||||
|
setVoiceState('idle');
|
||||||
|
}
|
||||||
|
// Chrome sometimes delays onend — don't leave the guest hanging.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!submittedRef.current) {
|
||||||
|
emitFinal();
|
||||||
|
setVoiceState('idle');
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}, [emitFinal]);
|
||||||
|
|
||||||
const resetTranscript = useCallback(() => {
|
const resetTranscript = useCallback(() => {
|
||||||
|
finalBufferRef.current = '';
|
||||||
|
interimRef.current = '';
|
||||||
|
submittedRef.current = false;
|
||||||
setTranscript('');
|
setTranscript('');
|
||||||
setInterimTranscript('');
|
setInterimTranscript('');
|
||||||
setVoiceState('idle');
|
setVoiceState('idle');
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ export const useSpeechSynthesis = (
|
||||||
|
|
||||||
const speak = useCallback(
|
const speak = useCallback(
|
||||||
(text: string, lang: string = 'en-IN', onEnd?: () => void) => {
|
(text: string, lang: string = 'en-IN', onEnd?: () => void) => {
|
||||||
if (!isSupported) return;
|
if (!isSupported) {
|
||||||
|
onEnd?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.speechSynthesis.cancel();
|
window.speechSynthesis.cancel();
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
|
@ -24,20 +27,27 @@ export const useSpeechSynthesis = (
|
||||||
utterance.pitch = 1.05;
|
utterance.pitch = 1.05;
|
||||||
utterance.volume = 1;
|
utterance.volume = 1;
|
||||||
|
|
||||||
utterance.onstart = () => {
|
let settled = false;
|
||||||
setVoiceState?.('speaking');
|
const finish = () => {
|
||||||
};
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
utterance.onend = () => {
|
|
||||||
setVoiceState?.('idle');
|
setVoiceState?.('idle');
|
||||||
onEnd?.();
|
onEnd?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
utterance.onerror = () => {
|
utterance.onstart = () => {
|
||||||
setVoiceState?.('idle');
|
setVoiceState?.('speaking');
|
||||||
};
|
};
|
||||||
|
utterance.onend = finish;
|
||||||
|
utterance.onerror = finish;
|
||||||
|
|
||||||
|
try {
|
||||||
window.speechSynthesis.speak(utterance);
|
window.speechSynthesis.speak(utterance);
|
||||||
|
// Firefox can leave speech pending without onend — unblock UI.
|
||||||
|
window.setTimeout(finish, 5000);
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[isSupported, setVoiceState]
|
[isSupported, setVoiceState]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
import App from './App.tsx'
|
import { CustomerApp } from './apps/customer/App';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<CustomerApp />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,146 @@
|
||||||
import { type MenuItem, MENU_ITEMS } from '../data/menuData';
|
import { type MenuItem, MENU_ITEMS, CATEGORIES } from '../data/menuData';
|
||||||
|
import type { CategoryType } from '../data/menuData';
|
||||||
|
import { debugLog, maskToken } from '../utils/debugLog';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
|
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
|
||||||
|
const DEMO_FALLBACK = import.meta.env.VITE_DEMO_FALLBACK === 'true';
|
||||||
|
|
||||||
|
export type PublicMenuItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_hindi?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
price: number | string;
|
||||||
|
prep_time_minutes?: number | null;
|
||||||
|
dietary_type?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
spice_level?: string | null;
|
||||||
|
is_available: boolean;
|
||||||
|
is_chef_special: boolean;
|
||||||
|
is_bestseller: boolean;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicMenuCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
display_order: number;
|
||||||
|
items: PublicMenuItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicMenuResponse = {
|
||||||
|
restaurant_id: number;
|
||||||
|
updated_at?: string | null;
|
||||||
|
categories: PublicMenuCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StaffMenuItem = {
|
||||||
|
id: number;
|
||||||
|
restaurant_id: number;
|
||||||
|
category_id: number | null;
|
||||||
|
category_name?: string | null;
|
||||||
|
name: string;
|
||||||
|
name_hindi?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
price: number | string;
|
||||||
|
gst_rate: number | string;
|
||||||
|
prep_time_minutes?: number | null;
|
||||||
|
dietary_type?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
spice_level?: string | null;
|
||||||
|
is_available: boolean;
|
||||||
|
is_chef_special: boolean;
|
||||||
|
is_bestseller: boolean;
|
||||||
|
tags: { id: number; tag_type: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StaffMenuCategory = {
|
||||||
|
id: number;
|
||||||
|
restaurant_id: number;
|
||||||
|
name: string;
|
||||||
|
display_order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUiDietary = (value?: string | null): MenuItem['dietary'] => {
|
||||||
|
if (value === 'non_veg' || value === 'non-veg') return 'non-veg';
|
||||||
|
if (value === 'vegan' || value === 'jain' || value === 'veg') return value;
|
||||||
|
return 'veg';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const flattenPublicMenu = (catalog: PublicMenuResponse): MenuItem[] => {
|
||||||
|
return catalog.categories.flatMap((category) =>
|
||||||
|
category.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
nameHindi: item.name_hindi || undefined,
|
||||||
|
category: (CATEGORIES.includes(category.name as CategoryType)
|
||||||
|
? category.name
|
||||||
|
: 'Specials') as MenuItem['category'],
|
||||||
|
price: Number(item.price),
|
||||||
|
prepTimeMinutes: item.prep_time_minutes ?? 10,
|
||||||
|
dietary: toUiDietary(item.dietary_type),
|
||||||
|
isChefSpecial: item.is_chef_special,
|
||||||
|
isBestseller: item.is_bestseller,
|
||||||
|
description: item.description || '',
|
||||||
|
image: item.image_url || '',
|
||||||
|
spiceLevel: (item.spice_level as MenuItem['spiceLevel']) || undefined,
|
||||||
|
tags: item.tags || [],
|
||||||
|
isAvailable: item.is_available,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Helpers to get/set auth tokens
|
// Helpers to get/set auth tokens
|
||||||
const getAccessToken = () => localStorage.getItem('access_token');
|
const getAccessToken = () => localStorage.getItem('access_token');
|
||||||
|
export { getAccessToken };
|
||||||
export const getRefreshToken = () => localStorage.getItem('refresh_token');
|
export const getRefreshToken = () => localStorage.getItem('refresh_token');
|
||||||
const setTokens = (access: string, refresh: string) => {
|
const setTokens = (access: string, refresh: string) => {
|
||||||
localStorage.setItem('access_token', access);
|
localStorage.setItem('access_token', access);
|
||||||
localStorage.setItem('refresh_token', refresh);
|
localStorage.setItem('refresh_token', refresh);
|
||||||
};
|
};
|
||||||
const clearTokens = () => {
|
export const clearTokens = () => {
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
|
const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
|
||||||
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id');
|
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id');
|
||||||
const setCustomerSession = (token: string, sessionId: string, tableId: string, restaurantId: string) => {
|
const setCustomerSession = (
|
||||||
|
token: string,
|
||||||
|
sessionId: string,
|
||||||
|
tableId: string,
|
||||||
|
restaurantId: string,
|
||||||
|
tableNumber?: string,
|
||||||
|
) => {
|
||||||
sessionStorage.setItem('customer_session_token', token);
|
sessionStorage.setItem('customer_session_token', token);
|
||||||
sessionStorage.setItem('customer_session_id', sessionId);
|
sessionStorage.setItem('customer_session_id', sessionId);
|
||||||
sessionStorage.setItem('customer_table_id', tableId);
|
sessionStorage.setItem('customer_table_id', tableId);
|
||||||
sessionStorage.setItem('customer_restaurant_id', restaurantId);
|
sessionStorage.setItem('customer_restaurant_id', restaurantId);
|
||||||
|
if (tableNumber) {
|
||||||
|
sessionStorage.setItem('customer_table_number', tableNumber);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
export const clearCustomerSession = () => {
|
export const clearCustomerSession = () => {
|
||||||
sessionStorage.removeItem('customer_session_token');
|
sessionStorage.removeItem('customer_session_token');
|
||||||
sessionStorage.removeItem('customer_session_id');
|
sessionStorage.removeItem('customer_session_id');
|
||||||
sessionStorage.removeItem('customer_table_id');
|
sessionStorage.removeItem('customer_table_id');
|
||||||
sessionStorage.removeItem('customer_restaurant_id');
|
sessionStorage.removeItem('customer_restaurant_id');
|
||||||
|
sessionStorage.removeItem('customer_table_number');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map front-end item ID (e.g. 'm1') to back-end numeric ID
|
/** @deprecated Prefer numeric catalog ids from GET /menu/public */
|
||||||
export const getBackendMenuId = (frontendId: string): number => {
|
export const getBackendMenuId = (frontendId: string | number): number => {
|
||||||
return parseInt(frontendId.replace(/[^0-9]/g, '')) || 1;
|
if (typeof frontendId === 'number') return frontendId;
|
||||||
|
return parseInt(String(frontendId).replace(/[^0-9]/g, ''), 10) || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map back-end ID to front-end menu item
|
export const getFrontendMenuItem = (
|
||||||
export const getFrontendMenuItem = (backendId: number | string): MenuItem | undefined => {
|
backendId: number | string,
|
||||||
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId)) || 1;
|
catalog: MenuItem[] = MENU_ITEMS,
|
||||||
return MENU_ITEMS.find(m => m.id === `m${numId}`) || MENU_ITEMS[0];
|
): MenuItem | undefined => {
|
||||||
|
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId), 10) || 1;
|
||||||
|
return catalog.find((m) => m.id === numId) || catalog[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connection State tracking
|
// Connection State tracking
|
||||||
|
|
@ -107,6 +209,13 @@ const mockDb = {
|
||||||
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
||||||
const url = `${BASE_URL}${path}`;
|
const url = `${BASE_URL}${path}`;
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
|
const method = (options.method || 'GET').toUpperCase();
|
||||||
|
const isAiOrSession =
|
||||||
|
path.startsWith('/ai') ||
|
||||||
|
path.includes('/start-session') ||
|
||||||
|
path.startsWith('/tables/public') ||
|
||||||
|
path.startsWith('/menu/public');
|
||||||
|
const started = performance.now();
|
||||||
|
|
||||||
// Inject Staff Auth Token
|
// Inject Staff Auth Token
|
||||||
const staffToken = getAccessToken();
|
const staffToken = getAccessToken();
|
||||||
|
|
@ -124,13 +233,27 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
|
||||||
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
// Let the browser set multipart boundary for FormData
|
||||||
|
if (options.body instanceof FormData && headers.has('Content-Type')) {
|
||||||
|
headers.delete('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAiOrSession) {
|
||||||
|
debugLog.info('api', `${method} ${path} →`, {
|
||||||
|
hasSession: Boolean(customerToken),
|
||||||
|
sessionToken: maskToken(customerToken),
|
||||||
|
hasStaff: Boolean(staffToken),
|
||||||
|
bodyPreview:
|
||||||
|
typeof options.body === 'string' ? options.body.slice(0, 180) : options.body ? '(FormData)' : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...options, headers });
|
const res = await fetch(url, { ...options, headers });
|
||||||
triggerDemoMode(false);
|
triggerDemoMode(false);
|
||||||
|
|
||||||
if (res.status === 401 && staffToken) {
|
if (res.status === 401 && staffToken && !customerToken) {
|
||||||
// Token expired, clear it
|
// Only clear staff auth when this was a staff-only request.
|
||||||
clearTokens();
|
clearTokens();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
throw new Error('Staff session expired. Please log in again.');
|
throw new Error('Staff session expired. Please log in again.');
|
||||||
|
|
@ -144,11 +267,49 @@ async function apiRequest(path: string, options: RequestInit = {}): Promise<any>
|
||||||
} catch {
|
} catch {
|
||||||
parsedErr = { error: errText };
|
parsedErr = { error: errText };
|
||||||
}
|
}
|
||||||
throw new Error(parsedErr.error || parsedErr.message || `Request failed: ${res.status}`);
|
const detail = parsedErr.detail;
|
||||||
|
const detailText =
|
||||||
|
typeof detail === 'string'
|
||||||
|
? detail
|
||||||
|
: Array.isArray(detail)
|
||||||
|
? detail.map((d: { msg?: string }) => d.msg || JSON.stringify(d)).join('; ')
|
||||||
|
: detail
|
||||||
|
? JSON.stringify(detail)
|
||||||
|
: '';
|
||||||
|
const message =
|
||||||
|
detailText ||
|
||||||
|
parsedErr.error ||
|
||||||
|
parsedErr.message ||
|
||||||
|
`Request failed: ${res.status}`;
|
||||||
|
if (isAiOrSession) {
|
||||||
|
debugLog.error('api', `${method} ${path} failed`, {
|
||||||
|
status: res.status,
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
detail: String(message).slice(0, 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await res.json();
|
const json = await res.json();
|
||||||
|
if (isAiOrSession) {
|
||||||
|
debugLog.info('api', `${method} ${path} ok`, {
|
||||||
|
status: res.status,
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAiOrSession) {
|
||||||
|
debugLog.error('api', `${method} ${path} exception`, {
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!DEMO_FALLBACK) {
|
||||||
|
throw error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
|
console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
|
||||||
triggerDemoMode(true);
|
triggerDemoMode(true);
|
||||||
return handleMockRequest(path, options);
|
return handleMockRequest(path, options);
|
||||||
|
|
@ -317,9 +478,47 @@ function handleMockRequest(path: string, options: RequestInit): any {
|
||||||
return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
|
return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. KDS Board
|
// 7. KDS Board + staff test order
|
||||||
if (path.startsWith('/kds/')) {
|
if (path.startsWith('/kds/')) {
|
||||||
return mockDb.orders;
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
// /kds/{id}/test-order
|
||||||
|
if (parts.length >= 3 && parts[2] === 'test-order' && method === 'POST') {
|
||||||
|
const newOrder = {
|
||||||
|
id: `ord-mock-${Date.now()}`,
|
||||||
|
order_id: Date.now(),
|
||||||
|
ticketNumber: String(100 + mockDb.orders.length + 1),
|
||||||
|
table_number: 'T1',
|
||||||
|
channel: 'manual_dine_in',
|
||||||
|
orderType: 'Dine-In',
|
||||||
|
status: 'placed',
|
||||||
|
placed_at: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
timeElapsedMinutes: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `oi-${Date.now()}-1`,
|
||||||
|
menu_item_id: 9,
|
||||||
|
name: 'Butter Chicken',
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 380,
|
||||||
|
kds_status: 'queued',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `oi-${Date.now()}-2`,
|
||||||
|
menu_item_id: 16,
|
||||||
|
name: 'Garlic Naan',
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 60,
|
||||||
|
kds_status: 'queued',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockDb.orders.push(newOrder);
|
||||||
|
notifyWsListeners({ type: 'kds.item_updated' });
|
||||||
|
return newOrder;
|
||||||
|
}
|
||||||
|
// /kds/{id}/board
|
||||||
|
return { restaurant_id: Number(parts[1]) || 1, orders: mockDb.orders };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch KDS order item status
|
// Patch KDS order item status
|
||||||
|
|
@ -555,11 +754,16 @@ const notifyWsListeners = (event: any) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WsConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||||
|
|
||||||
export class RestroWebSocket {
|
export class RestroWebSocket {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private url: string;
|
private url: string;
|
||||||
private reconnectTimer: any = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isClosedIntentional = false;
|
private isClosedIntentional = false;
|
||||||
|
private hadOpenConnection = false;
|
||||||
|
onStateChange: ((state: WsConnectionState) => void) | null = null;
|
||||||
|
onReconnected: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(channel: 'kds' | 'orders', param: string) {
|
constructor(channel: 'kds' | 'orders', param: string) {
|
||||||
if (channel === 'kds') {
|
if (channel === 'kds') {
|
||||||
|
|
@ -571,15 +775,30 @@ export class RestroWebSocket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setState(state: WsConnectionState) {
|
||||||
|
this.onStateChange?.(state);
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
console.log('Skipping real WebSocket connection in Demo Mode');
|
console.log('Skipping real WebSocket connection in Demo Mode');
|
||||||
|
this.setState('open');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isClosedIntentional = false;
|
this.isClosedIntentional = false;
|
||||||
|
this.setState(this.hadOpenConnection ? 'reconnecting' : 'connecting');
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(this.url);
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
const reconnected = this.hadOpenConnection;
|
||||||
|
this.hadOpenConnection = true;
|
||||||
|
this.setState('open');
|
||||||
|
if (reconnected) {
|
||||||
|
this.onReconnected?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
@ -592,7 +811,10 @@ export class RestroWebSocket {
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
if (!this.isClosedIntentional) {
|
if (!this.isClosedIntentional) {
|
||||||
console.log('WebSocket closed. Attempting reconnect in 5s...');
|
console.log('WebSocket closed. Attempting reconnect in 5s...');
|
||||||
|
this.setState('reconnecting');
|
||||||
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||||
|
} else {
|
||||||
|
this.setState('closed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -602,6 +824,8 @@ export class RestroWebSocket {
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WebSocket connection setup failed', e);
|
console.error('WebSocket connection setup failed', e);
|
||||||
|
this.setState('reconnecting');
|
||||||
|
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -612,6 +836,7 @@ export class RestroWebSocket {
|
||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
this.setState('closed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,19 +871,143 @@ export const api = {
|
||||||
return !!getAccessToken();
|
return !!getAccessToken();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMe: async (): Promise<{
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
restaurant_id: number;
|
||||||
|
home_restaurant_id?: number;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
restaurants?: { id: number; name: string; is_home: boolean; is_active: boolean }[];
|
||||||
|
}> => {
|
||||||
|
return apiRequest('/auth/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
switchRestaurant: async (restaurantId: number) => {
|
||||||
|
const res = await apiRequest('/auth/switch-restaurant', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ restaurant_id: restaurantId }),
|
||||||
|
});
|
||||||
|
if (res.access_token) {
|
||||||
|
setTokens(res.access_token, res.refresh_token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
aiChatTurn: async (payload: {
|
||||||
|
transcript: string;
|
||||||
|
language?: string;
|
||||||
|
submit?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
transcript: string;
|
||||||
|
assistant_text: string;
|
||||||
|
cart: {
|
||||||
|
menu_item_id: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number | string;
|
||||||
|
notes: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
}[];
|
||||||
|
order: { order_id: number; subtotal: number | string; channel: string; item_count: number } | null;
|
||||||
|
tool_calls: string[];
|
||||||
|
}> => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), 25_000);
|
||||||
|
try {
|
||||||
|
return await apiRequest('/ai/chat-turn', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('AI is taking too long — please try again in a moment.');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Staff JWT: inventory / kitchen assistant. */
|
||||||
|
staffAiChatTurn: async (payload: {
|
||||||
|
transcript: string;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<{
|
||||||
|
transcript: string;
|
||||||
|
assistant_text: string;
|
||||||
|
tool_calls: string[];
|
||||||
|
low_stock: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
current_stock: number;
|
||||||
|
reorder_threshold: number;
|
||||||
|
is_low: boolean;
|
||||||
|
}[];
|
||||||
|
updates?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
current_stock: number;
|
||||||
|
reorder_threshold: number;
|
||||||
|
is_low: boolean;
|
||||||
|
}[];
|
||||||
|
notify?: Record<string, unknown> | null;
|
||||||
|
}> => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), 25_000);
|
||||||
|
try {
|
||||||
|
return await apiRequest('/ai/staff-chat-turn', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('Staff AI is taking too long — please try again.');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Tables & Session
|
// Tables & Session
|
||||||
startSession: async (tableId: number, language = 'en') => {
|
startSession: async (tableId: number, language = 'en', tableNumber?: string) => {
|
||||||
const res = await apiRequest(`/tables/${tableId}/start-session`, {
|
const res = await apiRequest(`/tables/${tableId}/start-session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ language })
|
body: JSON.stringify({ language })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.session_token) {
|
if (res.session_token) {
|
||||||
setCustomerSession(res.session_token, String(res.session_id), String(res.table_id), String(res.restaurant_id));
|
setCustomerSession(
|
||||||
|
res.session_token,
|
||||||
|
String(res.session_id),
|
||||||
|
String(res.table_id),
|
||||||
|
String(res.restaurant_id),
|
||||||
|
tableNumber,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Guest table picker (no auth). Defaults to first restaurant. */
|
||||||
|
getPublicTables: async (opts?: {
|
||||||
|
restaurantId?: number;
|
||||||
|
availableOnly?: boolean;
|
||||||
|
}): Promise<
|
||||||
|
{ id: number; restaurant_id: number; table_number: string; capacity: number; status: string }[]
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.restaurantId != null) params.set('restaurant_id', String(opts.restaurantId));
|
||||||
|
if (opts?.availableOnly === false) params.set('available_only', 'false');
|
||||||
|
const qs = params.toString() ? `?${params}` : '';
|
||||||
|
return apiRequest(`/tables/public${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
closeSession: async (tableId: number) => {
|
closeSession: async (tableId: number) => {
|
||||||
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' });
|
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
|
|
@ -694,8 +1043,17 @@ export const api = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Staff KDS: create a sample ticket without a guest session. */
|
||||||
|
createKdsTestOrder: async (restaurantId: string | number) => {
|
||||||
|
return apiRequest(`/kds/${restaurantId}/test-order`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
getOrderForSession: async (sessionId: string) => {
|
getOrderForSession: async (sessionId: string) => {
|
||||||
return apiRequest(`/orders/${sessionId}`);
|
const data = await apiRequest(`/orders/${sessionId}`);
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.length ? data[data.length - 1] : null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getStaffOrderForSession: async (sessionId: string) => {
|
getStaffOrderForSession: async (sessionId: string) => {
|
||||||
|
|
@ -718,10 +1076,39 @@ export const api = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Billing
|
// Billing
|
||||||
generateBill: async (orderId: string) => {
|
generateBill: async (orderId: string | number) => {
|
||||||
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' });
|
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Customer session: generate or return existing bill for an order. */
|
||||||
|
sessionGenerateBill: async (orderId: string | number) => {
|
||||||
|
return apiRequest(`/session/orders/${orderId}/generate-bill`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionGetBill: async (billId: string | number) => {
|
||||||
|
return apiRequest(`/session/bills/${billId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionCheckoutRazorpay: async (billId: string | number) => {
|
||||||
|
return apiRequest(`/session/bills/${billId}/checkout/razorpay`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionConfirmRazorpay: async (
|
||||||
|
billId: string | number,
|
||||||
|
payload: {
|
||||||
|
razorpay_order_id: string;
|
||||||
|
razorpay_payment_id: string;
|
||||||
|
razorpay_signature: string;
|
||||||
|
amount?: number;
|
||||||
|
method?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return apiRequest(`/session/bills/${billId}/confirm-razorpay`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
recordPayment: async (billId: string, method: string, amount: number) => {
|
recordPayment: async (billId: string, method: string, amount: number) => {
|
||||||
return apiRequest(`/bills/${billId}/record-payment`, {
|
return apiRequest(`/bills/${billId}/record-payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -766,6 +1153,10 @@ export const api = {
|
||||||
return apiRequest('/inventory/low-stock');
|
return apiRequest('/inventory/low-stock');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notifyLowStock: async () => {
|
||||||
|
return apiRequest('/inventory/notify-low-stock', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
// Recipes
|
// Recipes
|
||||||
getRecipe: async (menuItemId: number) => {
|
getRecipe: async (menuItemId: number) => {
|
||||||
return apiRequest(`/menu-items/${menuItemId}/recipe`);
|
return apiRequest(`/menu-items/${menuItemId}/recipe`);
|
||||||
|
|
@ -779,6 +1170,65 @@ export const api = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Menu catalog
|
||||||
|
getPublicMenu: async (): Promise<PublicMenuResponse> => {
|
||||||
|
const known =
|
||||||
|
typeof localStorage !== 'undefined'
|
||||||
|
? localStorage.getItem('menu_catalog_updated_at') || ''
|
||||||
|
: '';
|
||||||
|
const qs = known ? `?updated_at=${encodeURIComponent(known)}` : '';
|
||||||
|
const data = (await apiRequest(`/menu/public${qs}`)) as PublicMenuResponse;
|
||||||
|
if (data?.updated_at && typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('menu_catalog_updated_at', data.updated_at);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
listMenuCategories: async (): Promise<StaffMenuCategory[]> => {
|
||||||
|
return apiRequest('/menu/categories');
|
||||||
|
},
|
||||||
|
|
||||||
|
createMenuCategory: async (payload: { name: string; display_order?: number }) => {
|
||||||
|
return apiRequest('/menu/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMenuCategory: async (id: number, payload: { name?: string; display_order?: number }) => {
|
||||||
|
return apiRequest(`/menu/categories/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMenuCategory: async (id: number) => {
|
||||||
|
return apiRequest(`/menu/categories/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
listMenuItems: async (categoryId?: number): Promise<StaffMenuItem[]> => {
|
||||||
|
const qs = categoryId != null ? `?category_id=${categoryId}` : '';
|
||||||
|
return apiRequest(`/menu/items${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createMenuItem: async (payload: Record<string, unknown>): Promise<StaffMenuItem> => {
|
||||||
|
return apiRequest('/menu/items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMenuItem: async (id: number, payload: Record<string, unknown>): Promise<StaffMenuItem> => {
|
||||||
|
return apiRequest(`/menu/items/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMenuItem: async (id: number) => {
|
||||||
|
return apiRequest(`/menu/items/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
// Suppliers CRUD
|
// Suppliers CRUD
|
||||||
getSuppliers: async () => {
|
getSuppliers: async () => {
|
||||||
return apiRequest('/suppliers');
|
return apiRequest('/suppliers');
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,2 @@
|
||||||
import { createTheme } from '@mui/material/styles';
|
/** Re-export shared theme from @restroai/ui. */
|
||||||
|
export { theme } from '@restroai/ui';
|
||||||
export const theme = createTheme({
|
|
||||||
palette: {
|
|
||||||
mode: 'light',
|
|
||||||
primary: {
|
|
||||||
main: '#ac2d00',
|
|
||||||
light: '#ffb5a0',
|
|
||||||
dark: '#872100',
|
|
||||||
contrastText: '#ffffff',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: '#546067',
|
|
||||||
light: '#818e95',
|
|
||||||
dark: '#2a363d',
|
|
||||||
contrastText: '#ffffff',
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
default: '#f8f9fa',
|
|
||||||
paper: '#ffffff',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: '#ba1a1a',
|
|
||||||
light: '#ffdad6',
|
|
||||||
dark: '#93000a',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: '#845000',
|
|
||||||
light: '#ffddba',
|
|
||||||
dark: '#2b1700',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
main: '#11651d',
|
|
||||||
light: '#a3f69c',
|
|
||||||
dark: '#003915',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
main: '#00a6e0',
|
|
||||||
light: '#c4e7ff',
|
|
||||||
dark: '#00374d',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: '#1a1c1c',
|
|
||||||
secondary: '#5b4139',
|
|
||||||
},
|
|
||||||
divider: '#e4beb4',
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
fontFamily: '"Inter", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
h1: {
|
|
||||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
},
|
|
||||||
h2: {
|
|
||||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '-0.01em',
|
|
||||||
},
|
|
||||||
h3: {
|
|
||||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
|
||||||
fontWeight: 700,
|
|
||||||
},
|
|
||||||
h4: {
|
|
||||||
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
h5: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
h6: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
subtitle1: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
body1: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
},
|
|
||||||
body2: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
lineHeight: 1.43,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
fontFamily: '"Inter", sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'none',
|
|
||||||
},
|
|
||||||
caption: {
|
|
||||||
fontFamily: '"JetBrains Mono", monospace',
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiButton: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 16px',
|
|
||||||
boxShadow: 'none',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0px 2px 8px rgba(172, 45, 0, 0.25)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contained: {
|
|
||||||
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiCard: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 12,
|
|
||||||
boxShadow: '0px 2px 12px rgba(0, 0, 0, 0.05)',
|
|
||||||
border: '1px solid rgba(228, 190, 180, 0.4)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiChip: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiAppBar: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
color: '#1a1c1c',
|
|
||||||
boxShadow: '0px 1px 10px rgba(0,0,0,0.05)',
|
|
||||||
borderBottom: '1px solid #e2e2e2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"target": "es2023",
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@restroai/ui": ["packages/ui/src/index.ts"],
|
||||||
|
"@restroai/ui/*": ["packages/ui/src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|
@ -16,11 +21,10 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "packages/ui/src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,86 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
})
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg', 'icons.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'RestroAI Guest',
|
||||||
|
short_name: 'RestroAI',
|
||||||
|
description: 'Scan, order, and pay at your table',
|
||||||
|
theme_color: '#ac2d00',
|
||||||
|
background_color: '#fff9f7',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icons.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/staff\.html/, /^\/api/],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: ({ url }) => url.pathname.includes('/menu/public'),
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'restroai-menu',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 20,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: ({ request }) => request.destination === 'image',
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'restroai-images',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 60,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Dev uses Vite HMR; SW/precache only in production builds (avoids empty dev-dist warning).
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@restroai/ui': path.resolve(__dirname, 'packages/ui/src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: path.resolve(__dirname, 'index.html'),
|
||||||
|
staff: path.resolve(__dirname, 'staff.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue