This commit is contained in:
2026-10-29 16:51:26 +03:00
parent 70a20665ae
commit 4b2f0903b1
51 changed files with 6540 additions and 1212 deletions
+853
View File
@@ -0,0 +1,853 @@
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;