853 lines
47 KiB
TypeScript
853 lines
47 KiB
TypeScript
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; |