584 lines
20 KiB
TypeScript
584 lines
20 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Paper,
|
|
IconButton,
|
|
Chip,
|
|
TextField,
|
|
InputAdornment,
|
|
Card,
|
|
CardMedia,
|
|
Badge,
|
|
BottomNavigation,
|
|
BottomNavigationAction,
|
|
Button,
|
|
Snackbar,
|
|
Alert,
|
|
} from '@mui/material';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import RemoveIcon from '@mui/icons-material/Remove';
|
|
import SearchIcon from '@mui/icons-material/Search';
|
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
|
import MicIcon from '@mui/icons-material/Mic';
|
|
import HomeIcon from '@mui/icons-material/Home';
|
|
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
|
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
|
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|
import StarIcon from '@mui/icons-material/Star';
|
|
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
|
|
import type { MenuItem, CategoryType } from '../../data/menuData';
|
|
import { StatusBadge } from '../common/StatusBadge';
|
|
import { useLocale } from '../../i18n/LocaleContext';
|
|
import { CartPeekDrawer } from './CartPeekDrawer';
|
|
|
|
interface MenuBrowseViewProps {
|
|
cart: { [itemId: string]: number };
|
|
cartNotes?: { [itemId: string]: string };
|
|
menuItems?: MenuItem[];
|
|
onUpdateCart: (itemId: string, quantity: number) => void;
|
|
onOpenCustomization: (item: MenuItem) => void;
|
|
onOpenVoice: () => void;
|
|
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
|
|
}
|
|
|
|
const DIETARY_FILTERS = [
|
|
{ label: 'All', value: null },
|
|
{ label: '🟢 Veg', value: 'veg' },
|
|
{ label: '🔴 Non-Veg', value: 'non-veg' },
|
|
{ label: '🌿 Jain', value: 'jain' },
|
|
];
|
|
|
|
const SPICE_ICONS: Record<string, string> = {
|
|
mild: '🟡',
|
|
medium: '🟠',
|
|
hot: '🔴',
|
|
'extra-hot': '🔥',
|
|
};
|
|
|
|
const tableLabel = () =>
|
|
sessionStorage.getItem('customer_table_number') ||
|
|
sessionStorage.getItem('customer_table_id') ||
|
|
'—';
|
|
|
|
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
|
|
cart,
|
|
cartNotes = {},
|
|
menuItems = MENU_ITEMS,
|
|
onUpdateCart,
|
|
onOpenCustomization,
|
|
onOpenVoice,
|
|
onNavigate,
|
|
}) => {
|
|
const { t } = useLocale();
|
|
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
|
|
const [bottomNav, setBottomNav] = useState(1);
|
|
const [cartPeekOpen, setCartPeekOpen] = useState(false);
|
|
const [emptyHint, setEmptyHint] = useState(false);
|
|
|
|
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
|
|
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
|
|
const item = menuItems.find((m) => String(m.id) === id);
|
|
return sum + (item ? item.price * qty : 0);
|
|
}, 0);
|
|
|
|
const cartLines = useMemo(
|
|
() =>
|
|
Object.entries(cart)
|
|
.map(([id, qty]) => {
|
|
const item = menuItems.find((m) => String(m.id) === id);
|
|
if (!item) return null;
|
|
return { item, qty, notes: cartNotes[id] };
|
|
})
|
|
.filter(Boolean) as { item: MenuItem; qty: number; notes?: string }[],
|
|
[cart, cartNotes, menuItems]
|
|
);
|
|
|
|
const filteredItems = useMemo(() => {
|
|
return menuItems.filter((item) => {
|
|
if (item.isAvailable === false) return false;
|
|
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
|
|
const matchSearch =
|
|
!searchQuery ||
|
|
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(item.nameHindi && item.nameHindi.includes(searchQuery)) ||
|
|
item.description.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
|
|
return matchCategory && matchSearch && matchDietary;
|
|
});
|
|
}, [menuItems, activeCategory, searchQuery, dietaryFilter]);
|
|
|
|
const openCartCheck = () => {
|
|
setCartPeekOpen(true);
|
|
};
|
|
|
|
const handleCartTap = () => {
|
|
openCartCheck();
|
|
if (totalCartItems === 0) setEmptyHint(true);
|
|
};
|
|
|
|
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
|
|
setBottomNav(newValue);
|
|
if (newValue === 0) onNavigate('landing');
|
|
if (newValue === 1) setBottomNav(1);
|
|
if (newValue === 2) {
|
|
openCartCheck();
|
|
if (totalCartItems === 0) setEmptyHint(true);
|
|
setBottomNav(1);
|
|
}
|
|
if (newValue === 3) onNavigate('status');
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
|
|
<Box
|
|
sx={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 100,
|
|
bgcolor: '#ffffff',
|
|
borderBottom: '1px solid #f0ebe7',
|
|
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
|
|
}}
|
|
>
|
|
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
|
|
Menu
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
|
|
Table {tableLabel()} · Tap dishes to add · Check cart anytime
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
|
<IconButton
|
|
onClick={onOpenVoice}
|
|
aria-label="Voice order"
|
|
sx={{ bgcolor: '#ffdbd1', color: '#ac2d00', '&:hover': { bgcolor: '#ffcab8' } }}
|
|
>
|
|
<MicIcon />
|
|
</IconButton>
|
|
<Badge badgeContent={totalCartItems} color="error">
|
|
<IconButton
|
|
onClick={handleCartTap}
|
|
aria-label="View cart"
|
|
sx={{
|
|
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
|
|
color: totalCartItems > 0 ? '#fff' : '#8f7068',
|
|
'&:hover': { bgcolor: totalCartItems > 0 ? '#872100' : '#ede8e4' },
|
|
}}
|
|
>
|
|
<ShoppingCartIcon />
|
|
</IconButton>
|
|
</Badge>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ px: 2, pb: 1 }}>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
placeholder="Search dishes…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
slotProps={{
|
|
input: {
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon sx={{ color: '#8f7068', fontSize: 18 }} />
|
|
</InputAdornment>
|
|
),
|
|
},
|
|
}}
|
|
sx={{
|
|
'& .MuiOutlinedInput-root': {
|
|
borderRadius: '12px',
|
|
bgcolor: '#f8f5f2',
|
|
'& fieldset': { border: '1px solid #e4ddd8' },
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 0.8,
|
|
px: 2,
|
|
pb: 1,
|
|
overflowX: 'auto',
|
|
'&::-webkit-scrollbar': { display: 'none' },
|
|
}}
|
|
>
|
|
{CATEGORIES.map((cat) => {
|
|
const catEmoji: Record<string, string> = {
|
|
All: '🍽️',
|
|
Starters: '🥗',
|
|
Biryani: '🍚',
|
|
Mains: '🍛',
|
|
Breads: '🫓',
|
|
Drinks: '🥤',
|
|
Desserts: '🍮',
|
|
Specials: '⭐',
|
|
};
|
|
const isActive = activeCategory === cat;
|
|
return (
|
|
<Chip
|
|
key={cat}
|
|
label={`${catEmoji[cat]} ${cat}`}
|
|
onClick={() => setActiveCategory(cat)}
|
|
sx={{
|
|
fontWeight: 800,
|
|
flexShrink: 0,
|
|
bgcolor: isActive ? '#ac2d00' : '#ffffff',
|
|
color: isActive ? '#fff' : '#5b4139',
|
|
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
|
|
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.25, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
|
|
{DIETARY_FILTERS.map((f) => (
|
|
<Chip
|
|
key={f.label}
|
|
label={f.label}
|
|
size="small"
|
|
onClick={() => setDietaryFilter(f.value)}
|
|
variant={dietaryFilter === f.value ? 'filled' : 'outlined'}
|
|
color={dietaryFilter === f.value ? 'primary' : 'default'}
|
|
sx={{ fontWeight: 700, flexShrink: 0 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ px: 2, pt: 1.5 }}>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 1.75,
|
|
borderRadius: '16px',
|
|
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
|
|
color: '#fff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
mb: 1.5,
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={onOpenVoice}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: '50%',
|
|
bgcolor: 'rgba(255,255,255,0.2)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<AutoAwesomeIcon />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Order with AI voice
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
|
Say what you want — we add it to your cart
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
|
|
<Box sx={{ flex: 1, px: 2, pb: totalCartItems > 0 ? 22 : 14 }}>
|
|
{activeCategory !== 'All' && (
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ fontWeight: 800, color: '#5b4139', mb: 1.25, textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
|
>
|
|
{activeCategory} ({filteredItems.length})
|
|
</Typography>
|
|
)}
|
|
|
|
{filteredItems.length === 0 ? (
|
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>
|
|
No dishes found
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 2 }}>
|
|
Try a different search or filter
|
|
</Typography>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setDietaryFilter(null);
|
|
setActiveCategory('All');
|
|
}}
|
|
sx={{ borderRadius: '9999px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
|
|
>
|
|
Clear filters
|
|
</Button>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
{filteredItems.map((item) => {
|
|
const qty = cart[String(item.id)] || 0;
|
|
return (
|
|
<Card
|
|
key={item.id}
|
|
elevation={0}
|
|
sx={{
|
|
borderRadius: '16px',
|
|
border: qty > 0 ? '1.5px solid #ac2d00' : '1px solid #f0ebe7',
|
|
overflow: 'hidden',
|
|
bgcolor: '#ffffff',
|
|
display: 'flex',
|
|
boxShadow: '0 2px 10px rgba(0,0,0,0.04)',
|
|
}}
|
|
>
|
|
<Box sx={{ position: 'relative', width: 112, flexShrink: 0 }}>
|
|
<CardMedia
|
|
component="img"
|
|
image={item.image}
|
|
alt={item.name}
|
|
sx={{ width: 112, height: '100%', minHeight: 112, objectFit: 'cover' }}
|
|
/>
|
|
{(item.isChefSpecial || item.isBestseller) && (
|
|
<Chip
|
|
icon={
|
|
item.isChefSpecial ? (
|
|
<AutoAwesomeIcon sx={{ fontSize: '12px !important' }} />
|
|
) : (
|
|
<StarIcon sx={{ fontSize: '12px !important', color: '#FFD700' }} />
|
|
)
|
|
}
|
|
label={item.isChefSpecial ? 'Special' : 'Hit'}
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 6,
|
|
left: 6,
|
|
height: 22,
|
|
fontSize: '0.62rem',
|
|
fontWeight: 800,
|
|
bgcolor: 'rgba(0,0,0,0.7)',
|
|
color: '#fff',
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
<Box sx={{ p: 1.5, flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, mb: 0.35 }}>
|
|
<Typography sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.25, fontSize: '0.95rem' }}>
|
|
{item.name}
|
|
</Typography>
|
|
<StatusBadge dietary={item.dietary} />
|
|
</Box>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: '#6b5c57',
|
|
mb: 1,
|
|
fontSize: '0.75rem',
|
|
lineHeight: 1.4,
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{item.description}
|
|
</Typography>
|
|
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 900, color: '#ac2d00', fontSize: '1rem' }}>₹{item.price}</Typography>
|
|
<Typography variant="caption" sx={{ color: '#8f7068' }}>
|
|
{item.spiceLevel ? `${SPICE_ICONS[item.spiceLevel] || ''} ` : ''}
|
|
{item.prepTimeMinutes} min
|
|
</Typography>
|
|
</Box>
|
|
|
|
{qty === 0 ? (
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
startIcon={<AddIcon />}
|
|
onClick={() => onOpenCustomization(item)}
|
|
sx={{
|
|
borderRadius: '9999px',
|
|
fontWeight: 800,
|
|
bgcolor: '#ac2d00',
|
|
px: 1.75,
|
|
boxShadow: 'none',
|
|
'&:hover': { bgcolor: '#872100', boxShadow: 'none' },
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
bgcolor: '#ac2d00',
|
|
borderRadius: '9999px',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => onUpdateCart(String(item.id), qty - 1)}
|
|
sx={{ color: '#fff', p: 0.7 }}
|
|
>
|
|
<RemoveIcon fontSize="small" />
|
|
</IconButton>
|
|
<Typography sx={{ px: 1.25, color: '#fff', fontWeight: 900, minWidth: 20, textAlign: 'center' }}>
|
|
{qty}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => onUpdateCart(String(item.id), qty + 1)}
|
|
sx={{ color: '#fff', p: 0.7 }}
|
|
>
|
|
<AddIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Card>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{totalCartItems > 0 && (
|
|
<Box
|
|
sx={{
|
|
position: 'fixed',
|
|
bottom: 72,
|
|
left: 12,
|
|
right: 12,
|
|
zIndex: 200,
|
|
maxWidth: 520,
|
|
mx: 'auto',
|
|
}}
|
|
>
|
|
<Paper
|
|
elevation={10}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'stretch',
|
|
borderRadius: '16px',
|
|
overflow: 'hidden',
|
|
bgcolor: '#1a1c1c',
|
|
color: '#fff',
|
|
boxShadow: '0 10px 28px rgba(0,0,0,0.28)',
|
|
}}
|
|
>
|
|
<Box
|
|
onClick={openCartCheck}
|
|
sx={{
|
|
flex: 1,
|
|
px: 2,
|
|
py: 1.5,
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Typography variant="caption" sx={{ opacity: 0.8, fontWeight: 700 }}>
|
|
View cart · {totalCartItems} item{totalCartItems === 1 ? '' : 's'}
|
|
</Typography>
|
|
<Typography sx={{ fontWeight: 900, fontSize: '1.05rem' }}>₹{totalCartValue.toFixed(0)}</Typography>
|
|
</Box>
|
|
<Button
|
|
onClick={() => onNavigate('cart')}
|
|
sx={{
|
|
px: 2.5,
|
|
borderRadius: 0,
|
|
bgcolor: '#ac2d00',
|
|
color: '#fff',
|
|
fontWeight: 900,
|
|
whiteSpace: 'nowrap',
|
|
'&:hover': { bgcolor: '#872100' },
|
|
}}
|
|
>
|
|
Checkout →
|
|
</Button>
|
|
</Paper>
|
|
</Box>
|
|
)}
|
|
|
|
<Paper
|
|
elevation={8}
|
|
sx={{
|
|
position: 'fixed',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 100,
|
|
borderTop: '1px solid #f0ebe7',
|
|
}}
|
|
>
|
|
<BottomNavigation value={bottomNav} onChange={handleBottomNav} sx={{ bgcolor: '#ffffff', height: 64 }}>
|
|
<BottomNavigationAction label={t('brand')} icon={<HomeIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
|
<BottomNavigationAction label={t('menu')} icon={<MenuBookIcon />} sx={{ '&.Mui-selected': { color: '#ac2d00' } }} />
|
|
<BottomNavigationAction
|
|
label={t('cart')}
|
|
icon={
|
|
<Badge badgeContent={totalCartItems} color="error">
|
|
<ShoppingCartIcon />
|
|
</Badge>
|
|
}
|
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
|
/>
|
|
<BottomNavigationAction
|
|
label={t('orderStatus')}
|
|
icon={<TrackChangesIcon />}
|
|
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
|
|
/>
|
|
</BottomNavigation>
|
|
</Paper>
|
|
|
|
<CartPeekDrawer
|
|
open={cartPeekOpen}
|
|
lines={cartLines}
|
|
subtotal={totalCartValue}
|
|
onClose={() => setCartPeekOpen(false)}
|
|
onUpdateCart={onUpdateCart}
|
|
onContinueShopping={() => setCartPeekOpen(false)}
|
|
onCheckout={() => {
|
|
setCartPeekOpen(false);
|
|
onNavigate('cart');
|
|
}}
|
|
/>
|
|
|
|
<Snackbar
|
|
open={emptyHint}
|
|
autoHideDuration={2800}
|
|
onClose={() => setEmptyHint(false)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
sx={{ bottom: { xs: 140, sm: 140 } }}
|
|
>
|
|
<Alert severity="info" onClose={() => setEmptyHint(false)} sx={{ fontWeight: 700, borderRadius: '12px' }}>
|
|
Cart is empty — add a dish, then check it here anytime.
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
);
|
|
};
|