RestroAi/src/components/customer/MenuBrowseView.tsx

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>
);
};