Files
electron/frontend/src/components/FoodMenu.tsx
T
2026-10-29 16:51:26 +03:00

853 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FaArrowLeft, FaShoppingCart, FaPlus, FaMinus, FaTrashAlt,
FaWallet, FaPaperPlane, FaCheckCircle, FaSpinner, FaUtensils, FaTimes
} from 'react-icons/fa';
const API_BASE = 'http://localhost:3001';
const BOTTOM_BAR_HEIGHT = 70;
// ============================================================
// ТИПЫ
// ============================================================
interface FoodItem {
id: number;
name: string;
description?: string;
price: number;
imageUrl?: string;
weightGrams?: number;
}
interface Category {
id: number;
name: string;
emoji?: string;
items: FoodItem[];
}
interface FoodMenuProps {
onBack: () => void;
employee: any;
}
type View = 'menu' | 'success';
const CATEGORY_EMOJI_FALLBACK: Record<number, string> = {
1: '🍲', 2: '🥣', 3: '🥪', 4: '🥤', 5: '🍰',
};
const getInitials = (fullName: string) =>
fullName.split(' ').filter(Boolean).slice(0, 2)
.map((s) => s[0]?.toUpperCase()).join('');
// ============================================================
// ГЛАВНЫЙ КОМПОНЕНТ
// ============================================================
const FoodMenu: React.FC<FoodMenuProps> = ({ onBack, employee }) => {
const tnom = employee?.tnom || '';
const fullName = employee
? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim()
: 'Сотрудник';
const [company, setCompany] = useState('АзотСервис');
const [categories, setCategories] = useState<Category[]>([]);
const [activeCat, setActiveCat] = useState<number | null>(null);
const [balance, setBalance] = useState<number>(0);
const [cart, setCart] = useState<Record<number, number>>({});
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [view, setView] = useState<View>('menu');
const [isCartOpen, setIsCartOpen] = useState(false);
const [successOrder, setSuccessOrder] = useState<{ id: number; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
// Состояния для "Мои заказы"
const [showMyOrders, setShowMyOrders] = useState(false);
const [myOrders, setMyOrders] = useState<any[]>([]);
const [loadingOrders, setLoadingOrders] = useState(false);
const [cancelConfirm, setCancelConfirm] = useState<{ id: number; total: number } | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
try {
const [menuRes, balRes] = await Promise.all([
fetch(`${API_BASE}/api/food/menu`).then((r) => r.json()),
tnom ? fetch(`${API_BASE}/api/food/balance/${tnom}`).then((r) => r.json())
: Promise.resolve({ balance: 0 }),
]);
setCompany(menuRes.company || 'АзотСервис');
setCategories(menuRes.categories || []);
setActiveCat(menuRes.categories?.[0]?.id ?? null);
setBalance(Number(balRes.balance) || 0);
} catch (e) {
setError('Не удалось загрузить меню');
} finally {
setLoading(false);
}
})();
}, [tnom]);
const allItems = useMemo(() => {
const map: Record<number, FoodItem> = {};
categories.forEach((c) => c.items.forEach((i) => (map[i.id] = i)));
return map;
}, [categories]);
const cartCount = useMemo(
() => Object.values(cart).reduce((s, q) => s + q, 0), [cart]
);
const cartTotal = useMemo(
() => +Object.entries(cart).reduce((s, [id, qty]) => {
const it = allItems[Number(id)];
return it ? s + it.price * qty : s;
}, 0).toFixed(2),
[cart, allItems]
);
const changeQty = (itemId: number, delta: number) => {
setCart((prev) => {
const next = { ...prev };
const qty = (next[itemId] || 0) + delta;
if (qty <= 0) delete next[itemId];
else next[itemId] = qty;
return next;
});
};
const removeItem = (itemId: number) => {
setCart((prev) => {
const n = { ...prev };
delete n[itemId];
return n;
});
};
const activeItems = categories.find((c) => c.id === activeCat)?.items || [];
const submitOrder = async () => {
if (cartCount === 0) return;
setSending(true);
setError(null);
try {
const items = Object.entries(cart).map(([id, qty]) => ({
itemId: Number(id), quantity: qty,
}));
const res = await fetch(`${API_BASE}/api/food/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tnom, items }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка отправки заказа');
setSending(false);
return;
}
setSuccessOrder({ id: data.orderId, total: data.total });
setBalance((b) => +(b - data.total).toFixed(2));
setCart({});
setIsCartOpen(false);
setView('success');
} catch (e: any) {
setError(e.message || 'Ошибка сети');
} finally {
setSending(false);
}
};
const handleDone = () => {
setSuccessOrder(null);
setView('menu');
};
// ============================================
// МОИ ЗАКАЗЫ
// ============================================
const fetchMyOrders = async () => {
if (!tnom) return;
setLoadingOrders(true);
try {
const res = await fetch(`${API_BASE}/api/food/my-orders/${tnom}`);
if (res.ok) {
const data = await res.json();
setMyOrders(data);
}
} catch (e) {
console.error(e);
} finally {
setLoadingOrders(false);
}
};
const handleOpenMyOrders = () => {
setShowMyOrders(true);
fetchMyOrders();
};
const handleCancelClick = (orderId: number, totalAmount: number) => {
setCancelConfirm({ id: orderId, total: totalAmount });
};
const confirmCancelOrder = async () => {
if (!cancelConfirm) return;
const { id, total } = cancelConfirm;
setCancelConfirm(null);
try {
const res = await fetch(`${API_BASE}/api/food/my-orders/${id}/cancel`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tnom }),
});
const data = await res.json();
if (res.ok) {
setBalance((b) => +(b + data.refund).toFixed(2));
fetchMyOrders();
} else {
alert(data.error || 'Ошибка отмены заказа');
}
} catch (e) {
alert('Ошибка сети при отмене заказа');
}
};
if (loading) {
return (
<div style={loadingStyle}>
<div style={spinnerStyle} />
<style>{keyframes}</style>
</div>
);
}
if (view === 'success' && successOrder) {
return (
<div style={successWrapperStyle}>
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
style={successCardStyle}
>
<div style={{ background: '#E8F5E9', borderRadius: '50%', padding: 20, display: 'inline-block', marginBottom: 20 }}>
<FaCheckCircle style={{ fontSize: 80, color: '#4CAF50' }} />
</div>
<h1 style={{ margin: '0 0 12px', color: '#2E3B32', fontSize: 36, fontWeight: 800 }}>
Заказ оформлен!
</h1>
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0' }}>
Номер заказа: <b style={{ color: '#2E3B32' }}>#{successOrder.id}</b>
</p>
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0 32px' }}>
Сумма: <b style={{ color: '#2E3B32' }}>{successOrder.total.toFixed(2)} BYN</b>
</p>
<button onClick={handleDone} style={successBtnStyle}>
Готово
</button>
</motion.div>
</div>
);
}
const entries = Object.entries(cart);
const notEnough = cartTotal > balance;
return (
<div style={menuRootStyle}>
{/* ============ ШАПКА ============ */}
<div style={headerZoneStyle}>
{/* 1-я строка: Кнопка Назад (10%) + Название столовой + Мои заказы */}
<div style={headerRow1Style}>
<button onClick={onBack} style={btnRectBackStyle}>
<FaArrowLeft size={20} />
</button>
<div style={companyTitleStyle}>
<FaUtensils size={32} style={{ color: '#4CAF50' }} />
<span>{company}</span>
</div>
{/* Кнопка Мои заказы */}
<button
onClick={handleOpenMyOrders}
style={{
marginLeft: 'auto',
background: '#FFFFFF', color: '#4CAF50', border: '2px solid #4CAF50',
borderRadius: 12, padding: '10px 20px', fontSize: 16, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}
>
<FaShoppingCart size={18} /> Мои заказы
</button>
</div>
{/* 2-я строка: Сотрудник + Баланс */}
<div style={headerRow2Style}>
<div style={headerCardStyle}>
<div style={avatarCircleStyle}>{getInitials(fullName) || '👤'}</div>
<div style={{ minWidth: 0 }}>
<div style={headerLabelStyle}>Сотрудник</div>
<div style={headerValueStyle} title={fullName}>{fullName}</div>
</div>
</div>
<div style={{
...headerCardStyle,
background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
border: 'none',
boxShadow: '0 8px 24px rgba(76,175,80,0.25)',
flex: '0 1 auto',
minWidth: 260,
}}>
<FaWallet size={28} style={{ color: '#fff' }} />
<div style={{ minWidth: 0 }}>
<div style={{ ...headerLabelStyle, color: 'rgba(255,255,255,0.9)' }}>Баланс</div>
<div style={{ ...headerValueStyle, color: '#fff', fontSize: 22 }}>
{balance.toFixed(2)} BYN
</div>
</div>
</div>
</div>
</div>
{/* ============ ОСНОВНАЯ ЗОНА ============ */}
<div style={mainZoneStyle}>
{/* Вертикальная колонка категорий */}
<div style={categoriesPanelStyle}>
<div style={categoriesTitleStyle}>КАТЕГОРИИ</div>
<div style={categoriesListStyle}>
{categories.map((c) => {
const isActive = c.id === activeCat;
return (
<button
key={c.id}
onClick={() => { setActiveCat(c.id); setExpandedItem(null); }}
style={{
...categoryBtnStyle,
background: isActive ? '#E2F0D9' : 'transparent',
color: isActive ? '#4CAF50' : '#546E7A',
fontWeight: isActive ? 800 : 600,
}}
>
<span style={{ fontSize: 32 }}>{c.emoji || '🍽️'}</span>
<span style={{ fontSize: 18 }}>{c.name}</span>
</button>
);
})}
</div>
</div>
{/* Сетка блюд */}
<div style={itemsPanelStyle}>
{activeItems.map((it) => {
const qty = cart[it.id] || 0;
const cat = categories.find((c) => c.id === activeCat);
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
const isExpanded = expandedItem === it.id || qty > 0;
return (
<div
key={it.id}
style={{
...itemCardStyle,
border: isExpanded ? '2px solid #81C784' : '2px solid transparent',
}}
onClick={() => {
if (qty === 0) {
changeQty(it.id, 1);
setExpandedItem(it.id);
}
}}
>
<div style={itemImgStyle}>
{it.imageUrl && !imageErrors[it.id] ? (
<img
src={`${API_BASE}${it.imageUrl}`}
alt={it.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => {
setImageErrors(prev => ({ ...prev, [it.id]: true }));
}}
/>
) : (
emoji
)}
</div>
<div style={itemInfoStyle}>
<div style={itemNameStyle}>{it.name}</div>
{it.description && <div style={itemDescStyle}>{it.description}</div>}
<div style={{ flex: 1 }} />
<div style={itemBottomStyle}>
<div style={itemPriceStyle}>
{it.price.toFixed(2)}
<span style={{ fontSize: 14, color: '#90A4AE', marginLeft: 4, fontWeight: 500 }}>BYN</span>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
style={qtyInlineStyle}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
changeQty(it.id, -1);
if (qty <= 1) setExpandedItem(null);
}}
style={qtyItemBtnStyle}
>
<FaMinus size={16} />
</button>
<span style={qtyInlineNumStyle}>{qty}</span>
<button
onClick={() => changeQty(it.id, 1)}
style={qtyItemBtnStyle}
>
<FaPlus size={16} />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* ============ ПЛАВАЮЩАЯ ПЛАШКА КОРЗИНЫ ============ */}
<AnimatePresence>
{cartCount > 0 && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
style={cartBarStyle}
onClick={() => setIsCartOpen(true)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ background: 'rgba(255,255,255,0.2)', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FaShoppingCart size={22} />
</div>
<span style={{ fontWeight: 600, fontSize: 20 }}>
{cartCount} поз.
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<span style={{ fontWeight: 800, fontSize: 24 }}>
{cartTotal.toFixed(2)} BYN
</span>
<div style={{ background: '#fff', color: '#4CAF50', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', fontSize: 24 }}>
→
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ============ ВЫЕЗЖАЮЩАЯ КОРЗИНА СПРАВА ============ */}
<AnimatePresence>
{isCartOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
onClick={() => setIsCartOpen(false)}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
zIndex: 201, display: 'flex', flexDirection: 'column',
paddingBottom: BOTTOM_BAR_HEIGHT
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Ваш заказ</h2>
<button onClick={() => setIsCartOpen(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<FaTimes size={20} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{entries.map(([idStr, qty]) => {
const id = Number(idStr);
const it = allItems[id];
if (!it) return null;
const cat = categories.find((c) => c.items.some((i) => i.id === id));
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
return (
<div key={id} style={cartItemStyle}>
<div style={cartItemImgStyle}>
{it.imageUrl && !imageErrors[it.id] ? (
<img
src={`${API_BASE}${it.imageUrl}`}
alt={it.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => {
setImageErrors(prev => ({ ...prev, [it.id]: true }));
}}
/>
) : (
emoji
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={cartItemNameStyle}>{it.name}</div>
<div style={cartItemPriceStyle}>{it.price.toFixed(2)} BYN</div>
</div>
<div style={qtyControlsStyle}>
<button onClick={() => changeQty(id, -1)} style={qtyBigBtnStyle}><FaMinus size={16} /></button>
<span style={qtyNumStyle}>{qty}</span>
<button onClick={() => changeQty(id, 1)} style={qtyBigBtnStyle}><FaPlus size={16} /></button>
</div>
<div style={{ fontSize: 20, fontWeight: 800, color: '#2E3B32', minWidth: 80, textAlign: 'right' }}>
{(it.price * qty).toFixed(2)}
</div>
<button onClick={() => removeItem(id)} style={removeBtnStyle}><FaTrashAlt size={20} /></button>
</div>
);
})}
</div>
<div style={{ padding: '24px', borderTop: '1px solid #E0E6ED', background: '#F7F9F6' }}>
{error && <div style={errorStyle}>{error}</div>}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#546E7A' }}>Итого:</div>
<div style={{ fontSize: 40, fontWeight: 800, color: '#2E3B32' }}>
{cartTotal.toFixed(2)} <span style={{ fontSize: 20, color: '#78909C' }}>BYN</span>
</div>
</div>
{notEnough && <div style={{ fontSize: 16, color: '#E74C3C', marginBottom: 16, fontWeight: 600, textAlign: 'center' }}>Недостаточно средств (баланс {balance.toFixed(2)})</div>}
<button
onClick={submitOrder}
disabled={sending || notEnough || entries.length === 0}
style={{
...submitBtnStyle,
background: sending || notEnough || entries.length === 0 ? '#B0BEC5' : 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
cursor: sending || notEnough || entries.length === 0 ? 'not-allowed' : 'pointer',
width: '100%',
}}
>
{sending ? <><FaSpinner size={24} style={{ animation: 'spin 1s linear infinite' }} /> Отправка…</> : <><FaPaperPlane size={24} /> Отправить заказ</>}
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* ============ ВЫЕЗЖАЮЩАЯ ПАНЕЛЬ "МОИ ЗАКАЗЫ" ============ */}
<AnimatePresence>
{showMyOrders && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
onClick={() => setShowMyOrders(false)}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
zIndex: 201, display: 'flex', flexDirection: 'column',
paddingBottom: BOTTOM_BAR_HEIGHT
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Мои заказы</h2>
<button onClick={() => setShowMyOrders(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<FaTimes size={20} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{loadingOrders ? (
<div style={{ textAlign: 'center', padding: 40, color: '#78909C' }}>Загрузка...</div>
) : myOrders.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#78909C', fontSize: 18 }}>У вас пока нет заказов</div>
) : (
myOrders.map((order) => {
let statusColor = '#78909C';
let statusText = order.status;
if (order.status === 'new') { statusColor = '#F39C12'; statusText = 'В обработке'; }
if (order.status === 'accepted') { statusColor = '#3498DB'; statusText = 'Готовится'; }
if (order.status === 'done') { statusColor = '#2ECC71'; statusText = 'Готов'; }
if (order.status === 'cancelled') { statusColor = '#E74C3C'; statusText = 'Отменен'; }
return (
<div key={order.id} style={{ background: '#F7F9F6', borderRadius: 16, padding: 20, border: '1px solid #E0E6ED' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontWeight: 800, fontSize: 18, color: '#2E3B32' }}>Заказ #{order.id}</div>
<div style={{
padding: '4px 12px', borderRadius: 20, fontSize: 14, fontWeight: 700,
background: `${statusColor}20`, color: statusColor,
border: `1px solid ${statusColor}50`
}}>
{statusText}
</div>
</div>
<div style={{ fontSize: 14, color: '#78909C', marginBottom: 12 }}>
{new Date(order.createdAt).toLocaleString('ru-RU')}
</div>
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 16, color: '#2E3B32', marginBottom: 12 }}>
{order.items.map((it: any, i: number) => (
<li key={i}>{it.name} <span style={{ color: '#78909C' }}>x{it.quantity}</span></li>
))}
</ul>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #E0E6ED' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#4CAF50' }}>
{order.totalAmount.toFixed(2)} BYN
</div>
{order.status === 'new' && (
<button
onClick={() => handleCancelClick(order.id, order.totalAmount)}
style={{
padding: '10px 20px',
background: '#FFEBEE',
flex: '0 0 100px',
color: '#E74C3C',
border: '2px solid #E74C3C',
borderRadius: 8,
cursor: 'pointer',
fontSize: 16,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<FaTimes size={16} /> Отменить
</button>
)}
</div>
</div>
);
})
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* ============ МИНИ-МОДАЛКА ПОДТВЕРЖДЕНИЯ ОТМЕНЫ ============ */}
<AnimatePresence>
{cancelConfirm && (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{
position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.5)', backdropFilter: 'blur(4px)',
zIndex: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20
}}
>
<motion.div
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
style={{
background: '#FFFFFF', borderRadius: 20, padding: '32px',
maxWidth: 460, width: '100%', textAlign: 'center',
boxShadow: '0 24px 80px rgba(0,0,0,0.2)'
}}
>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: '#FFEBEE', color: '#E74C3C',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 16px', fontSize: 28
}}>
<FaTimes />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: 22, fontWeight: 800, color: '#2E3B32' }}>
Отменить заказ?
</h3>
<p style={{ margin: '0 0 24px', color: '#607D8B', fontSize: 16 }}>
Заказ #{cancelConfirm.id} будет отменён.<br />
<b style={{ color: '#2E3B32' }}>{cancelConfirm.total.toFixed(2)} BYN</b> вернутся на баланс.
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={() => setCancelConfirm(null)}
style={{
flex: 1, padding: '14px', border: '2px solid #E0E6ED',
background: '#FFFFFF', color: '#546E7A', borderRadius: 12,
fontSize: 16, fontWeight: 700, cursor: 'pointer'
}}
>
Отмена
</button>
<button
onClick={confirmCancelOrder}
style={{
flex: 1, padding: '14px',
background: 'linear-gradient(135deg, #EF5350 0%, #E74C3C 100%)',
color: '#FFFFFF', border: 'none', borderRadius: 12,
fontSize: 16, fontWeight: 700, cursor: 'pointer',
boxShadow: '0 8px 24px rgba(231,76,60,0.3)'
}}
>
Да, отменить
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<style>{keyframes}</style>
</div>
);
};
// ============================================================
// СТИЛИ (FRESH MINT + КНОПКА 10%)
// ============================================================
const keyframes = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
const loadingStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: '#F7F9F6', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 };
const spinnerStyle: React.CSSProperties = { width: 60, height: 60, border: '5px solid #E2F0D9', borderTop: '5px solid #4CAF50', borderRadius: '50%', animation: 'spin 1s linear infinite' };
const menuRootStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
paddingTop: 90, paddingBottom: BOTTOM_BAR_HEIGHT,
background: '#F7F9F6', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box',
};
// Шапка
const headerZoneStyle: React.CSSProperties = {
padding: '16px 24px',
background: '#E2F0D9',
borderBottom: '1px solid #C8E6C9',
flexShrink: 0, zIndex: 10, boxShadow: '0 4px 16px rgba(76,175,80,0.05)',
display: 'flex', flexDirection: 'column', gap: 16,
};
// Первая строка шапки
const headerRow1Style: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 20, justifyContent: 'flex-start'
};
const btnRectBackStyle: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
flex: '0 0 100px',
height: 52,
background: '#FFFFFF', color: '#4CAF50',
border: 'none', borderRadius: 12, cursor: 'pointer',
fontSize: 16, fontWeight: 800, flexShrink: 0,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)', transition: 'all 0.2s',
};
const companyTitleStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
fontSize: 36, fontWeight: 800, color: '#2E3B32',
letterSpacing: '-0.5px'
};
// Вторая строка шапки (Сотрудник + Баланс)
const headerRow2Style: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' };
const headerCardStyle: React.CSSProperties = {
flex: '1 1 220px', display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
background: '#FFFFFF', borderRadius: 16, minWidth: 0, boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
};
const headerLabelStyle: React.CSSProperties = { fontSize: 12, color: '#607D8B', textTransform: 'uppercase', letterSpacing: 0.5, fontWeight: 800 };
const headerValueStyle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: 2 };
const avatarCircleStyle: React.CSSProperties = { width: 44, height: 44, borderRadius: '50%', background: '#E2F0D9', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 800, flexShrink: 0 };
const mainZoneStyle: React.CSSProperties = { flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' };
// Категории
const categoriesPanelStyle: React.CSSProperties = {
width: 220, background: '#FFFFFF', borderRight: '1px solid #E0E6ED',
display: 'flex', flexDirection: 'column', padding: '16px 0', overflowY: 'auto', flexShrink: 0,
};
const categoriesTitleStyle: React.CSSProperties = { fontSize: 13, fontWeight: 800, color: '#607D8B', padding: '0 24px 16px', letterSpacing: 0.5 };
const categoriesListStyle: React.CSSProperties = { flex: 1, overflowY: 'auto' };
const categoryBtnStyle: React.CSSProperties = {
width: '100%', padding: '20px 24px', border: 'none', borderRadius: 0, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 16, textAlign: 'left', transition: 'all 0.2s',
};
// Сетка блюд
const itemsPanelStyle: React.CSSProperties = {
flex: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 20, alignContent: 'start', overflowY: 'auto', padding: '24px', minHeight: 0,
};
const itemCardStyle: React.CSSProperties = {
background: '#FFFFFF', borderRadius: 20, overflow: 'hidden',
boxShadow: '0 4px 20px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', minWidth: 0, transition: 'all 0.2s',
};
const itemImgStyle: React.CSSProperties = {
height: 140, background: '#F7F9F6',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 72, overflow: 'hidden',
};
const itemInfoStyle: React.CSSProperties = { padding: '16px', flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 };
const itemNameStyle: React.CSSProperties = { fontSize: 17, fontWeight: 800, color: '#2E3B32', lineHeight: 1.3 };
const itemDescStyle: React.CSSProperties = {
fontSize: 13, color: '#78909C', marginTop: 6,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
};
const itemBottomStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16, gap: 8, flexWrap: 'wrap' };
const itemPriceStyle: React.CSSProperties = { fontSize: 20, fontWeight: 800, color: '#4CAF50' };
const qtyInlineStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 4, background: '#F0F4F8', borderRadius: 999, padding: '4px' };
const qtyItemBtnStyle: React.CSSProperties = {
width: 40, height: 40, borderRadius: '50%', background: '#FFFFFF', border: 'none',
cursor: 'pointer', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
};
const qtyInlineNumStyle: React.CSSProperties = { minWidth: 32, textAlign: 'center', fontWeight: 800, fontSize: 18, color: '#2E3B32' };
const cartBarStyle: React.CSSProperties = {
position: 'fixed', bottom: BOTTOM_BAR_HEIGHT + 12, left: '50%', transform: 'translateX(-50%)',
background: '#2E3B32', color: '#fff', borderRadius: 999, padding: '10px 16px 10px 12px',
display: 'flex', alignItems: 'center', gap: 32, boxShadow: '0 12px 40px rgba(46,59,50,0.3)',
cursor: 'pointer', zIndex: 100, minWidth: 380, justifyContent: 'space-between',
};
// Выезжающая корзина
const cartItemStyle: React.CSSProperties = {
background: '#FFFFFF', borderRadius: 16, padding: 16, display: 'flex', alignItems: 'center', gap: 16,
border: '1px solid #F0F4F8',
};
const cartItemImgStyle: React.CSSProperties = {
width: 60, height: 60, borderRadius: 12, background: '#F7F9F6',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, overflow: 'hidden', flexShrink: 0,
};
const cartItemNameStyle: React.CSSProperties = { fontSize: 16, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
const cartItemPriceStyle: React.CSSProperties = { fontSize: 14, color: '#78909C', marginTop: 4, fontWeight: 500 };
const qtyControlsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, background: '#F0F4F8', borderRadius: 999, padding: '4px 6px' };
const qtyBigBtnStyle: React.CSSProperties = {
width: 36, height: 36, borderRadius: '50%', background: '#FFFFFF', border: 'none',
cursor: 'pointer', color: '#2E3B32', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
};
const qtyNumStyle: React.CSSProperties = { minWidth: 28, textAlign: 'center', fontWeight: 800, fontSize: 16, color: '#2E3B32' };
const removeBtnStyle: React.CSSProperties = { background: 'transparent', border: 'none', color: '#EF5350', cursor: 'pointer', padding: 8 };
const submitBtnStyle: React.CSSProperties = {
color: '#fff', border: 'none', borderRadius: 16, padding: '20px 40px',
fontSize: 22, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
boxShadow: '0 8px 24px rgba(76,175,80,0.3)', transition: 'all 0.2s',
};
const errorStyle: React.CSSProperties = { background: '#FFEBEE', color: '#C62828', padding: '12px 18px', borderRadius: 12, fontSize: 16, marginBottom: 16, fontWeight: 600, textAlign: 'center' };
// SUCCESS
const successWrapperStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.7)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20 };
const successCardStyle: React.CSSProperties = { background: '#FFFFFF', borderRadius: 32, padding: '56px 64px', textAlign: 'center', maxWidth: 600, boxShadow: '0 24px 80px rgba(0,0,0,0.2)' };
const successBtnStyle: React.CSSProperties = { padding: '20px 64px', background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)', color: '#fff', border: 'none', borderRadius: 16, fontSize: 22, fontWeight: 800, cursor: 'pointer', boxShadow: '0 8px 24px rgba(76,175,80,0.3)' };
export default FoodMenu;