Initial commit: Harat's Booking monorepo with deploy setup

This commit is contained in:
Eugenius-Anonymous
2026-06-08 14:36:51 +07:00
commit c97f7fd1a8
89 changed files with 10536 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Забронируйте стол и блюда в Harat's Irish Pub — сети ирландских пабов. Выбирайте место, предзаказывайте блюда, оплачивайте онлайн." />
<title>Harat's Irish Pub — Бронирование</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@harats/client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@harats/shared": "*",
"axios": "^1.7.0",
"konva": "^9.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-konva": "^19.0.0",
"react-router-dom": "^7.1.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.62.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './router';
import { useAuthStore } from './features/auth/store';
import { Loader } from './components/Loader';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
export const App: React.FC = () => {
const { loadUser, isLoading } = useAuthStore();
useEffect(() => {
loadUser();
}, [loadUser]);
if (isLoading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Loader />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
};
export default App;
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'gold' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
isLoading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, variant = 'primary', size = 'md', fullWidth, isLoading, className = '', disabled, ...props }, ref) => {
let btnClass = 'btn';
// Variant
if (variant === 'primary') btnClass += ' btn-primary';
else if (variant === 'gold') btnClass += ' btn-gold';
else if (variant === 'outline') btnClass += ' btn-outline';
else if (variant === 'ghost') btnClass += ' btn-ghost';
else if (variant === 'danger') btnClass += ' btn-danger';
// Size
if (size === 'sm') btnClass += ' btn-sm';
else if (size === 'lg') btnClass += ' btn-lg';
// Full width
if (fullWidth) btnClass += ' btn-full';
return (
<button
ref={ref}
className={`${btnClass} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<svg className="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30" strokeLinecap="round" opacity="0.3"></circle>
<path d="M12 2A10 10 0 0 1 22 12" stroke="currentColor" strokeWidth="4" strokeLinecap="round"></path>
</svg>
) : children}
</button>
);
}
);
Button.displayName = 'Button';
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
export const Footer: React.FC = () => {
return (
<footer style={{
background: 'var(--color-bg-secondary)',
borderTop: '1px solid var(--color-border)',
padding: 'var(--space-3xl) 0',
marginTop: 'auto'
}}>
<div className="container">
<div className="grid grid-3 gap-xl">
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Harat's Irish Pub</h4>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>ООО «Хэрат'с»</p>
<p className="text-sm text-secondary">
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного отдыхать душой.
</p>
</div>
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Контакты</h4>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
<li>📞 8 (800) 775-03-39 (Customer Service)</li>
<li> <a href="mailto:info@harats.com" style={{ color: 'var(--color-green-400)' }}>info@harats.com</a></li>
<li style={{ marginTop: 'var(--space-sm)' }}>
📍 Главный офис:<br />
664033, Россия, Иркутская область, Иркутск, ул. Майская, 20
</li>
</ul>
</div>
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Информация</h4>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>О компании</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Франшиза</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Поставщикам</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Реклама</a></li>
</ul>
</div>
</div>
<div className="divider" style={{ borderColor: 'var(--color-border-light)' }}></div>
<div className="flex justify-between items-center text-xs text-muted">
<p>© {new Date().getFullYear()} Harat's Irish Pub. Все права защищены.</p>
<p>
<span style={{ display: 'inline-block', padding: '2px 8px', border: '1px solid currentColor', borderRadius: '4px', marginRight: '8px' }}>18+</span>
Чрезмерное употребление алкоголя вредит вашему здоровью.
</p>
</div>
</div>
</footer>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth/store';
import { Button } from './Button';
import { LoginModal } from '@/features/auth/components/LoginModal';
import { RegisterModal } from '@/features/auth/components/RegisterModal';
export const Header: React.FC = () => {
const { isAuthenticated, user } = useAuthStore();
const navigate = useNavigate();
const [isLoginOpen, setLoginOpen] = useState(false);
const [isRegisterOpen, setRegisterOpen] = useState(false);
const openRegister = () => {
setLoginOpen(false);
setRegisterOpen(true);
};
const openLogin = () => {
setRegisterOpen(false);
setLoginOpen(true);
};
return (
<>
<header style={{
position: 'sticky',
top: 0,
zIndex: 50,
height: 'var(--header-height)',
background: 'rgba(13, 13, 13, 0.85)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center'
}}>
<div className="container flex items-center justify-between">
<div style={{ flex: 1 }}>
{isAuthenticated ? (
<Button variant="ghost" onClick={() => navigate('/profile')}>
Личный кабинет
</Button>
) : (
<Button variant="ghost" onClick={() => setLoginOpen(true)}>
Вход / Регистрация
</Button>
)}
</div>
<div style={{ flex: 1, textAlign: 'center' }}>
<Link to="/" style={{ display: 'inline-block' }}>
<span className="text-display" style={{ color: 'var(--color-gold-500)', fontSize: '1.5rem', letterSpacing: '0.05em' }}>
Harat's
</span>
<span className="text-display" style={{ display: 'block', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.2em', color: 'var(--color-green-500)' }}>
Irish Pub
</span>
</Link>
</div>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)' }}>
{user?.role === 'ADMIN' && (
<Button variant="outline" onClick={() => navigate('/admin')}>Админ</Button>
)}
</div>
</div>
</header>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setLoginOpen(false)}
onSwitchToRegister={openRegister}
/>
<RegisterModal
isOpen={isRegisterOpen}
onClose={() => setRegisterOpen(false)}
onSwitchToLogin={openLogin}
/>
</>
);
};
+27
View File
@@ -0,0 +1,27 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', id, ...props }, ref) => {
const inputId = id || Math.random().toString(36).substr(2, 9);
return (
<div className={`input-group ${className}`}>
{label && <label htmlFor={inputId} className="input-label">{label}</label>}
<input
id={inputId}
ref={ref}
className={`input ${error ? 'input-error' : ''}`}
{...props}
/>
{error && <span className="error-text">{error}</span>}
</div>
);
}
);
Input.displayName = 'Input';
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export const Loader: React.FC = () => {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: 'var(--space-2xl)' }}>
<svg className="animate-spin" width="48" height="48" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="var(--color-bg-elevated)" strokeWidth="4"></circle>
<path d="M12 2A10 10 0 0 1 22 12" stroke="var(--color-gold-500)" strokeWidth="4" strokeLinecap="round"></path>
</svg>
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
wide?: boolean;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer, wide }) => {
if (!isOpen) return null;
return (
<div className="overlay" onClick={(e) => { if(e.target === e.currentTarget) onClose(); }}>
<div className={`modal ${wide ? 'modal-wide' : ''}`}>
<div className="modal-header">
<h3 className="m-0">{title}</h3>
<button className="modal-close" onClick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Modal } from '@/components/Modal';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useAuthStore } from '../store';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToRegister: () => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToRegister }) => {
const login = useAuthStore(state => state.login);
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(phone, password);
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка входа');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Вход в личный кабинет">
<form onSubmit={handleSubmit} className="flex flex-col gap-md">
{error && <div className="badge badge-red" style={{ padding: '8px 12px' }}>{error}</div>}
<Input
label="Номер телефона"
type="tel"
placeholder="+7 (999) 000-00-00"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
<Input
label="Пароль"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit" variant="primary" fullWidth size="lg" isLoading={isLoading} style={{ marginTop: 'var(--space-sm)' }}>
Войти
</Button>
<p className="text-center text-sm text-secondary mt-4">
Нет аккаунта?{' '}
<button type="button" onClick={onSwitchToRegister} style={{ color: 'var(--color-gold-400)', fontWeight: 600 }}>
Зарегистрироваться
</button>
</p>
</form>
</Modal>
);
};
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Modal } from '@/components/Modal';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useAuthStore } from '../store';
interface RegisterModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToLogin: () => void;
}
export const RegisterModal: React.FC<RegisterModalProps> = ({ isOpen, onClose, onSwitchToLogin }) => {
const register = useAuthStore(state => state.register);
const [formData, setFormData] = useState({
phone: '',
name: '',
surname: '',
email: '',
password: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await register(formData);
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка регистрации');
} finally {
setIsLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Регистрация">
<form onSubmit={handleSubmit} className="flex flex-col gap-md">
{error && <div className="badge badge-red" style={{ padding: '8px 12px' }}>{error}</div>}
<Input
label="Номер телефона *"
name="phone"
type="tel"
placeholder="+7 (999) 000-00-00"
value={formData.phone}
onChange={handleChange}
required
/>
<div className="grid grid-2 gap-md">
<Input
label="Имя *"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
<Input
label="Фамилия"
name="surname"
value={formData.surname}
onChange={handleChange}
/>
</div>
<Input
label="E-mail (для чеков)"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<Input
label="Пароль *"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
required
/>
<Button type="submit" variant="primary" fullWidth size="lg" isLoading={isLoading} style={{ marginTop: 'var(--space-sm)' }}>
Создать аккаунт
</Button>
<p className="text-center text-sm text-secondary mt-4">
Уже есть аккаунт?{' '}
<button type="button" onClick={onSwitchToLogin} style={{ color: 'var(--color-gold-400)', fontWeight: 600 }}>
Войти
</button>
</p>
</form>
</Modal>
);
};
+75
View File
@@ -0,0 +1,75 @@
import { create } from 'zustand';
import type { IUser } from '@harats/shared';
import api from '@/services/api';
interface AuthState {
user: IUser | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (phone: string, password: string) => Promise<void>;
register: (data: { phone: string; password: string; name: string; surname?: string; email?: string }) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
updateUser: (data: Partial<IUser>) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,
login: async (phone, password) => {
const { data } = await api.post('/auth/login', { phone, password });
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
set({ user: data.data.user, isAuthenticated: true });
} else {
throw new Error(data.error);
}
},
register: async (payload) => {
const { data } = await api.post('/auth/register', payload);
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
set({ user: data.data.user, isAuthenticated: true });
} else {
throw new Error(data.error);
}
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
},
loadUser: async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
set({ isLoading: false });
return;
}
try {
const { data } = await api.get('/users/me');
if (data.success) {
set({ user: data.data, isAuthenticated: true, isLoading: false });
} else {
set({ isLoading: false });
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ isLoading: false });
}
},
updateUser: (data) => {
set((state) => ({
user: state.user ? { ...state.user, ...data } : null,
}));
},
}));
@@ -0,0 +1,88 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/services/api';
import { useBookingStore } from '../store';
import { Loader } from '@/components/Loader';
import { Button } from '@/components/Button';
import type { IDish } from '@harats/shared';
interface DishSelectorProps {
pubId: number;
}
export const DishSelector: React.FC<DishSelectorProps> = ({ pubId }) => {
const { addDish, removeDish, updateDishQty, dishes: cartDishes } = useBookingStore();
const { data: menuRes, isLoading } = useQuery({
queryKey: ['menu', pubId],
queryFn: () => api.get(`/pubs/${pubId}/menu`).then(res => res.data)
});
if (isLoading) return <Loader />;
const menu: Record<string, any[]> = menuRes?.data || {};
return (
<div className="flex flex-col gap-xl">
{Object.entries(menu).map(([category, items]) => (
<div key={category}>
<h4 style={{ marginBottom: 'var(--space-md)', color: 'var(--color-gold-500)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-xs)' }}>
{category}
</h4>
<div className="grid grid-2 gap-md">
{items.map((item: any) => {
const dish: IDish = item.dish;
const inCart = cartDishes.find(d => d.dishId === dish.id);
const isAvailable = dish.availability === 'AVAILABLE';
return (
<div key={dish.id} className="card flex-col justify-between" style={{ padding: 'var(--space-md)', opacity: isAvailable ? 1 : 0.5 }}>
<div>
<div className="flex justify-between items-start mb-2">
<span className="font-semibold">{dish.title}</span>
<span style={{ color: 'var(--color-gold-400)' }}>{dish.price} </span>
</div>
<p className="text-sm text-secondary line-clamp-2" style={{ minHeight: '40px' }}>{dish.description}</p>
</div>
<div className="flex justify-end items-center mt-4" style={{ height: '36px' }}>
{inCart ? (
<div className="flex items-center gap-md">
<Button
variant="outline"
size="sm"
style={{ minWidth: '36px', minHeight: '36px', padding: 0 }}
onClick={() => updateDishQty(dish.id, inCart.quantity - 1)}
>
-
</Button>
<span>{inCart.quantity}</span>
<Button
variant="outline"
size="sm"
style={{ minWidth: '36px', minHeight: '36px', padding: 0 }}
onClick={() => updateDishQty(dish.id, inCart.quantity + 1)}
>
+
</Button>
</div>
) : (
<Button
variant="primary"
size="sm"
disabled={!isAvailable}
onClick={() => addDish({ dishId: dish.id, title: dish.title, price: dish.price, quantity: 1 })}
>
Добавить
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,140 @@
import React, { useRef, useEffect, useState } from 'react';
import { Stage, Layer, Rect, Circle, Text, Group } from 'react-konva';
import type { ITableAvailability } from '@harats/shared';
interface FloorPlanProps {
tables: ITableAvailability[];
selectedTableId: number | null;
onSelectTable: (id: number) => void;
}
export const FloorPlan: React.FC<FloorPlanProps> = ({ tables, selectedTableId, onSelectTable }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
// Responsive canvas sizing
useEffect(() => {
const resize = () => {
if (containerRef.current) {
const { width } = containerRef.current.getBoundingClientRect();
// Maintain aspect ratio or fix height, let's just make it scale responsive
// Scale down based on 800px base width
setDimensions({ width, height: 500 * (width / 800) });
}
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, []);
const scale = dimensions.width / 800; // 800 is our base coordinate system width
return (
<div ref={containerRef} style={{ width: '100%', background: 'var(--color-bg-elevated)', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<Stage width={dimensions.width} height={dimensions.height}>
<Layer>
{/* Background zones (Optional decorative) */}
<Rect x={0} y={0} width={800 * scale} height={320 * scale} fill="transparent" />
<Rect x={0} y={320 * scale} width={800 * scale} height={180 * scale} fill="rgba(200, 169, 81, 0.03)" />
<Text x={20 * scale} y={330 * scale} text="VIP & Terrace" fontSize={14 * scale} fill="var(--color-text-muted)" />
<Text x={560 * scale} y={20 * scale} text="Bar Area" fontSize={14 * scale} fill="var(--color-text-muted)" />
{tables.map(table => {
const isSelected = selectedTableId === table.id;
// Colors
let fillColor = 'var(--color-bg-card)';
let strokeColor = 'var(--color-border)';
if (isSelected) {
fillColor = 'var(--color-gold-500)';
strokeColor = 'var(--color-gold-600)';
} else if (table.isAvailable) {
fillColor = 'var(--color-green-700)';
strokeColor = 'var(--color-green-600)';
} else {
fillColor = 'var(--color-error-bg)';
strokeColor = 'var(--color-error)';
}
const x = table.posX * scale;
const y = table.posY * scale;
const w = table.width * scale;
const h = table.height * scale;
return (
<Group
key={table.id}
x={x}
y={y}
onClick={() => table.isAvailable && onSelectTable(table.id)}
onTap={() => table.isAvailable && onSelectTable(table.id)}
onMouseEnter={(e) => {
if (table.isAvailable) {
const stage = e.target.getStage();
if(stage) stage.container().style.cursor = 'pointer';
}
}}
onMouseLeave={(e) => {
const stage = e.target.getStage();
if(stage) stage.container().style.cursor = 'default';
}}
>
{table.shape === 'rect' ? (
<Rect
width={w}
height={h}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
cornerRadius={8 * scale}
opacity={table.isAvailable ? 1 : 0.6}
shadowColor="black"
shadowBlur={4}
shadowOpacity={0.2}
shadowOffset={{ x: 0, y: 2 }}
/>
) : (
<Circle
radius={w / 2}
x={w / 2}
y={w / 2}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
opacity={table.isAvailable ? 1 : 0.6}
shadowColor="black"
shadowBlur={4}
shadowOpacity={0.2}
shadowOffset={{ x: 0, y: 2 }}
/>
)}
<Text
text={table.number.toString()}
width={w}
height={h}
align="center"
verticalAlign="middle"
fill="white"
fontSize={14 * scale}
fontStyle="bold"
/>
<Text
text={`${table.capacity} чел`}
width={w}
height={h}
y={18 * scale}
align="center"
verticalAlign="middle"
fill="rgba(255,255,255,0.7)"
fontSize={10 * scale}
/>
</Group>
);
})}
</Layer>
</Stage>
</div>
);
};
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand';
interface BookingDish {
dishId: number;
title: string;
price: number;
quantity: number;
}
interface BookingState {
pubId: number | null;
tableId: number | null;
date: string;
time: string;
numPeople: number;
includeDishes: boolean;
dishes: BookingDish[];
setPubId: (id: number) => void;
setTableId: (id: number | null) => void;
setDate: (date: string) => void;
setTime: (time: string) => void;
setNumPeople: (n: number) => void;
setIncludeDishes: (v: boolean) => void;
addDish: (dish: BookingDish) => void;
removeDish: (dishId: number) => void;
updateDishQty: (dishId: number, qty: number) => void;
getTotalCost: () => number;
reset: () => void;
}
const initialState = {
pubId: null as number | null,
tableId: null as number | null,
date: '',
time: '',
numPeople: 2,
includeDishes: false,
dishes: [] as BookingDish[],
};
export const useBookingStore = create<BookingState>((set, get) => ({
...initialState,
setPubId: (id) => set({ pubId: id, tableId: null }),
setTableId: (id) => set({ tableId: id }),
setDate: (date) => set({ date, tableId: null }),
setTime: (time) => set({ time, tableId: null }),
setNumPeople: (n) => set({ numPeople: n }),
setIncludeDishes: (v) => set({ includeDishes: v, dishes: v ? get().dishes : [] }),
addDish: (dish) => {
const existing = get().dishes.find(d => d.dishId === dish.dishId);
if (existing) {
set({
dishes: get().dishes.map(d =>
d.dishId === dish.dishId ? { ...d, quantity: d.quantity + 1 } : d
),
});
} else {
set({ dishes: [...get().dishes, dish] });
}
},
removeDish: (dishId) => {
set({ dishes: get().dishes.filter(d => d.dishId !== dishId) });
},
updateDishQty: (dishId, qty) => {
if (qty <= 0) {
set({ dishes: get().dishes.filter(d => d.dishId !== dishId) });
} else {
set({
dishes: get().dishes.map(d =>
d.dishId === dishId ? { ...d, quantity: qty } : d
),
});
}
},
getTotalCost: () => {
return get().dishes.reduce((sum, d) => sum + d.price * d.quantity, 0);
},
reset: () => set(initialState),
}));
+709
View File
@@ -0,0 +1,709 @@
/* ═══════════════════════════════════════════
Harat's Irish Pub — Design System
Dark pub atmosphere, green & gold accents
═══════════════════════════════════════════ */
/* ─── Reset ──────────────────────────────── */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-body);
background: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
ul, ol { list-style: none; }
img { max-width: 100%; display: block; }
input, select, textarea { font: inherit; }
/* ─── Design Tokens ──────────────────────── */
:root {
/* Brand Colors */
--color-green-900: #0a2e1a;
--color-green-800: #134d2e;
--color-green-700: #1a6b3f;
--color-green-600: #228b52;
--color-green-500: #2eaa66;
--color-green-400: #4fc882;
--color-green-300: #7eddaa;
--color-gold-600: #a88a30;
--color-gold-500: #c8a951;
--color-gold-400: #d4bc6a;
--color-gold-300: #e0cf8a;
--color-gold-200: #eee0b0;
/* Surfaces */
--color-bg-primary: #0d0d0d;
--color-bg-secondary: #151515;
--color-bg-card: #1a1a1a;
--color-bg-elevated: #222222;
--color-bg-input: #1e1e1e;
--color-bg-hover: #2a2a2a;
/* Text */
--color-text-primary: #f0efe8;
--color-text-secondary: #a09e94;
--color-text-muted: #6b6960;
--color-text-inverse: #0d0d0d;
/* Borders */
--color-border: #2a2a2a;
--color-border-light: #333333;
--color-border-focus: var(--color-gold-500);
/* Semantic */
--color-error: #e54d4d;
--color-error-bg: #2a1515;
--color-success: #2eaa66;
--color-success-bg: #152a1a;
--color-warning: #d4a74a;
--color-warning-bg: #2a2415;
/* Typography */
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Spacing */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
--space-4xl: 96px;
/* Radius */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.8);
--shadow-glow-green: 0 0 20px rgba(46, 170, 102, 0.15);
--shadow-glow-gold: 0 0 20px rgba(200, 169, 81, 0.15);
/* Transitions */
--transition-fast: 120ms ease;
--transition-base: 200ms ease;
--transition-slow: 350ms ease;
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Layout */
--header-height: 72px;
--max-width: 1280px;
--sidebar-width: 280px;
}
/* ─── Scrollbar ──────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* ─── Typography ─────────────────────────── */
.text-display {
font-family: var(--font-display);
font-weight: 700;
line-height: 1.1;
}
h1 { font-family: var(--font-display); font-size: 2.5rem; font-weight: 700; line-height: 1.1; }
h2 { font-family: var(--font-display); font-size: 2rem; font-weight: 600; line-height: 1.2; }
h3 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 600; line-height: 1.3; }
h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-lg { font-size: 1.125rem; }
/* ─── Button Base ────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 12px 24px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 0.9375rem;
transition: all var(--transition-base);
white-space: nowrap;
min-height: 44px;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: white;
opacity: 0;
transition: opacity var(--transition-fast);
}
.btn:active::after {
opacity: 0.1;
}
.btn-primary {
background: linear-gradient(135deg, var(--color-green-600), var(--color-green-500));
color: white;
box-shadow: var(--shadow-sm), var(--shadow-glow-green);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-green-500), var(--color-green-400));
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow-green);
}
.btn-gold {
background: linear-gradient(135deg, var(--color-gold-600), var(--color-gold-500));
color: var(--color-text-inverse);
box-shadow: var(--shadow-sm), var(--shadow-glow-gold);
}
.btn-gold:hover {
background: linear-gradient(135deg, var(--color-gold-500), var(--color-gold-400));
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow-gold);
}
.btn-outline {
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border-light);
}
.btn-outline:hover {
border-color: var(--color-gold-500);
color: var(--color-gold-400);
background: rgba(200, 169, 81, 0.05);
}
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
}
.btn-ghost:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.btn-danger {
background: var(--color-error);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-sm {
padding: 8px 16px;
font-size: 0.8125rem;
min-height: 36px;
}
.btn-lg {
padding: 16px 32px;
font-size: 1.0625rem;
min-height: 52px;
border-radius: var(--radius-lg);
}
.btn-full {
width: 100%;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
}
/* ─── Input ──────────────────────────────── */
.input-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.input-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
}
.input {
width: 100%;
padding: 12px 16px;
background: var(--color-bg-input);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.9375rem;
transition: border-color var(--transition-base), box-shadow var(--transition-base);
min-height: 44px;
}
.input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(200, 169, 81, 0.1);
}
.input::placeholder {
color: var(--color-text-muted);
}
.input-error {
border-color: var(--color-error);
}
.error-text {
font-size: 0.75rem;
color: var(--color-error);
}
select.input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23a09e94' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 40px;
}
/* ─── Card ───────────────────────────────── */
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.card-hover:hover {
border-color: var(--color-border-light);
box-shadow: var(--shadow-md);
}
.card-glass {
background: rgba(26, 26, 26, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* ─── Badge ──────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-full);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-green {
background: var(--color-success-bg);
color: var(--color-success);
}
.badge-red {
background: var(--color-error-bg);
color: var(--color-error);
}
.badge-gold {
background: rgba(200, 169, 81, 0.12);
color: var(--color-gold-400);
}
.badge-gray {
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
}
/* ─── Modal / Overlay ────────────────────── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
animation: fadeIn var(--transition-base) ease;
}
.modal {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: slideUp var(--transition-slow) ease;
}
.modal-wide {
max-width: 800px;
}
.modal-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
padding: var(--space-lg);
}
.modal-footer {
padding: var(--space-lg);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.modal-close {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
.modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
/* ─── Tabs ───────────────────────────────── */
.tabs {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
transition: all var(--transition-base);
white-space: nowrap;
cursor: pointer;
}
.tab:hover {
color: var(--color-text-primary);
}
.tab-active {
color: var(--color-gold-400);
border-bottom-color: var(--color-gold-500);
}
/* ─── Table (data) ───────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: var(--space-sm) var(--space-md);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
}
.data-table td {
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.data-table tr:hover td {
background: var(--color-bg-hover);
}
/* ─── Checkbox / Toggle ──────────────────── */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
}
.checkbox {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid var(--color-border-light);
border-radius: var(--radius-xs);
background: var(--color-bg-input);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
}
.checkbox:checked {
background: var(--color-green-600);
border-color: var(--color-green-600);
}
.checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 12px;
font-weight: 700;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
background: var(--color-bg-elevated);
border-radius: var(--radius-full);
cursor: pointer;
transition: background var(--transition-base);
border: 1px solid var(--color-border);
}
.toggle.active {
background: var(--color-green-600);
border-color: var(--color-green-600);
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
transition: transform var(--transition-base);
}
.toggle.active::after {
transform: translateX(20px);
}
/* ─── Skeleton Loader ────────────────────── */
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-elevated) 25%,
var(--color-bg-hover) 50%,
var(--color-bg-elevated) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
/* ─── Toast / Notification ───────────────── */
.toast-container {
position: fixed;
top: calc(var(--header-height) + var(--space-md));
right: var(--space-md);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.toast {
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-lg);
font-size: 0.875rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideInRight var(--transition-slow) ease;
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 280px;
}
.toast-success {
background: var(--color-success-bg);
border: 1px solid var(--color-success);
color: var(--color-success);
}
.toast-error {
background: var(--color-error-bg);
border: 1px solid var(--color-error);
color: var(--color-error);
}
/* ─── Layout Utilities ───────────────────── */
.container {
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-lg);
}
.page {
min-height: calc(100vh - var(--header-height));
padding-top: var(--space-2xl);
padding-bottom: var(--space-3xl);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.gap-lg { gap: var(--space-lg); }
.gap-xl { gap: var(--space-xl); }
.grid {
display: grid;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr;
}
}
/* ─── Divider ────────────────────────────── */
.divider {
height: 1px;
background: var(--color-border);
border: none;
margin: var(--space-lg) 0;
}
/* ─── Animations ─────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in { animation: fadeIn var(--transition-base) ease; }
.animate-slide-up { animation: slideUp var(--transition-slow) ease; }
.animate-scale-in { animation: scaleIn var(--transition-spring); }
/* ─── Grain texture ──────────────────────── */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.015;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
export const MainLayout: React.FC = () => {
return (
<>
<Header />
<main className="page">
<Outlet />
</main>
<Footer />
</>
);
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+238
View File
@@ -0,0 +1,238 @@
import React, { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/services/api';
import { Loader } from '@/components/Loader';
import { useAuthStore } from '@/features/auth/store';
import { Navigate } from 'react-router-dom';
export const AdminPage: React.FC = () => {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'reservations' | 'stoplist'>('reservations');
const [categoryFilter, setCategoryFilter] = useState<string>('__all__');
if (user?.role !== 'ADMIN') {
return <Navigate to="/" />;
}
const { data: resData, isLoading: resLoading } = useQuery({
queryKey: ['admin-reservations'],
queryFn: () => api.get('/reservations').then(res => res.data)
});
const { data: dishesData, isLoading: dishesLoading } = useQuery({
queryKey: ['admin-dishes'],
queryFn: () => api.get('/dishes').then(res => res.data)
});
const updateStatusMutation = useMutation({
mutationFn: ({ id, status }: { id: number, status: string }) => api.put(`/reservations/${id}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-reservations'] });
}
});
const toggleAvailabilityMutation = useMutation({
mutationFn: ({ id, availability }: { id: number, availability: string }) => api.put(`/dishes/${id}/availability`, { availability }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-dishes'] });
}
});
const reservations = resData?.data || [];
const dishes = dishesData?.data || [];
const categories = useMemo(() => {
const set = new Set<string>();
for (const d of dishes) {
const title = d?.category?.title;
if (typeof title === 'string' && title.trim()) set.add(title);
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'ru'));
}, [dishes]);
const filteredDishes = useMemo(() => {
if (categoryFilter === '__all__') return dishes;
return dishes.filter((d: any) => d?.category?.title === categoryFilter);
}, [dishes, categoryFilter]);
const stoppedDishes = useMemo(() => {
return dishes.filter((d: any) => d?.availability === 'STOPPED');
}, [dishes]);
return (
<div className="container">
<h1 className="mb-6 font-display text-2xl mb-8" style={{ color: 'var(--color-gold-500)' }}>Панель Администратора</h1>
<div className="tabs mb-6">
<div
className={`tab ${activeTab === 'reservations' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('reservations')}
>
Бронирования
</div>
<div
className={`tab ${activeTab === 'stoplist' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('stoplist')}
>
Стоп-лист {stoppedDishes.length > 0 ? `(${stoppedDishes.length})` : ''}
</div>
</div>
{activeTab === 'reservations' && (
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800">
<h3 className="m-0">Управление бронями</h3>
</div>
{resLoading ? <Loader /> : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Дата / Время</th>
<th>Пользователь</th>
<th>Стол</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{reservations.map((r: any) => (
<tr key={r.id}>
<td>#{r.id}</td>
<td>{new Date(r.date + 'T00:00:00').toLocaleDateString('ru')}<br/><span className="text-muted">{r.time}</span></td>
<td>{r.user.name} {r.user.surname}<br/><span className="text-muted">{r.user.phone}</span></td>
<td>{r.pub.address.split(',')[0]}<br/><span className="text-gold-500 font-bold text-xs" style={{ color: 'var(--color-gold-500)' }}>Стол {r.table.number}</span> ({r.numPeople}ч)</td>
<td>
<span className={`badge ${r.status === 'CONFIRMED' ? 'badge-green' : r.status === 'PENDING' ? 'badge-gold' : r.status === 'CANCELLED' ? 'badge-red' : 'badge-gray'}`}>
{r.status}
</span>
</td>
<td>
<select
className="input"
style={{ padding: '4px 8px', minHeight: 'auto', fontSize: '12px' }}
value={r.status}
onChange={(e) => updateStatusMutation.mutate({ id: r.id, status: e.target.value })}
>
<option value="PENDING">Ожидает</option>
<option value="CONFIRMED">Подтверждена</option>
<option value="CANCELLED">Отменена</option>
<option value="COMPLETED">Завершена</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{activeTab === 'stoplist' && (
<div className="grid grid-2 gap-xl" style={{ gridTemplateColumns: 'minmax(0, 5fr) minmax(0, 7fr)' }}>
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800 flex justify-between items-center">
<h3 className="m-0">Блюда в стоп-листе</h3>
<span className="text-secondary text-sm">{stoppedDishes.length} шт.</span>
</div>
<div className="p-4 flex flex-col gap-sm" style={{ maxHeight: '600px', overflowY: 'auto' }}>
{dishesLoading ? <Loader /> : stoppedDishes.length === 0 ? (
<div className="text-secondary">Сейчас нет блюд в стоп-листе.</div>
) : stoppedDishes.map((dish: any) => (
<div
key={dish.id}
className="flex justify-between items-center p-3 rounded"
style={{ background: 'var(--color-error-bg)', border: `1px solid var(--color-error)` }}
>
<div className="font-semibold">
{dish.title}{' '}
<span className="text-muted text-xs font-normal">({dish.category.title})</span>
</div>
<ButtonOrLink
onClick={() => toggleAvailabilityMutation.mutate({ id: dish.id, availability: 'AVAILABLE' })}
/>
</div>
))}
</div>
</section>
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800 flex justify-between items-center">
<h3 className="m-0">Стоп-лист блюд</h3>
<span className="text-secondary text-sm">Вкл = в стопе</span>
</div>
<div className="p-4 border-b border-zinc-800 flex items-center gap-md" style={{ background: 'var(--color-bg-elevated)' }}>
<label className="text-secondary text-sm" style={{ minWidth: 90 }}>Категория</label>
<select
className="input"
style={{ padding: '6px 10px', minHeight: 'auto', fontSize: '12px' }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="__all__">Все</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="p-4 flex flex-col gap-sm" style={{ maxHeight: '540px', overflowY: 'auto' }}>
{dishesLoading ? <Loader /> : filteredDishes.map((dish: any) => (
<div
key={dish.id}
className="flex justify-between items-center p-3 rounded"
style={{
background: dish.availability === 'STOPPED' ? 'var(--color-error-bg)' : 'var(--color-bg-input)',
border: `1px solid ${dish.availability === 'STOPPED' ? 'var(--color-error)' : 'transparent'}`
}}
>
<div>
<div className="font-semibold">{dish.title} <span className="text-muted text-xs font-normal">({dish.category.title})</span></div>
</div>
<div
className="toggle-wrapper"
onClick={() => toggleAvailabilityMutation.mutate({ id: dish.id, availability: dish.availability === 'AVAILABLE' ? 'STOPPED' : 'AVAILABLE' })}
>
<div
className={`toggle ${dish.availability === 'STOPPED' ? 'active' : ''}`}
style={{
borderColor: dish.availability === 'STOPPED' ? 'var(--color-error)' : undefined,
background: dish.availability === 'STOPPED' ? 'var(--color-error)' : undefined
}}
/>
</div>
</div>
))}
</div>
</section>
</div>
)}
</div>
);
};
const ButtonOrLink: React.FC<{ onClick: () => void }> = ({ onClick }) => {
return (
<button
type="button"
className="btn btn-ghost"
style={{
padding: '6px 10px',
fontSize: '12px',
border: '1px solid var(--color-border-light)',
borderRadius: '999px',
color: 'var(--color-text)',
background: 'transparent',
cursor: 'pointer'
}}
onClick={onClick}
>
Убрать
</button>
);
};
+384
View File
@@ -0,0 +1,384 @@
import React, { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { useBookingStore } from '@/features/booking/store';
import { useAuthStore } from '@/features/auth/store';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { Loader } from '@/components/Loader';
import { FloorPlan } from '@/features/booking/components/FloorPlan';
import { DishSelector } from '@/features/booking/components/DishSelector';
export const BookingPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated, user } = useAuthStore();
const state = useBookingStore();
const [step, setStep] = useState<1 | 2 | 3>(1); // 1: Details & Table, 2: Dishes, 3: Payment/Confirm
const [paymentMethod, setPaymentMethod] = useState<'saved' | 'new'>('new');
const [paymentCardNumber, setPaymentCardNumber] = useState('');
const [paymentCardExpiry, setPaymentCardExpiry] = useState('');
const [paymentCardCvv, setPaymentCardCvv] = useState('');
// Queries
const { data: pubsRes, isLoading: pubsLoading } = useQuery({
queryKey: ['pubs'],
queryFn: () => api.get('/pubs').then(res => res.data)
});
const { data: tablesRes, isLoading: tablesLoading, isFetching: tablesFetching } = useQuery({
queryKey: ['tables-avail', state.pubId, state.date, state.time],
queryFn: () => api.get(`/pubs/${state.pubId}/tables/availability?date=${state.date}&time=${state.time}`).then(res => res.data),
enabled: !!(state.pubId && state.date && state.time)
});
// Mutations
const createReservationMutation = useMutation({
mutationFn: (data: any) => api.post('/reservations', data).then(res => res.data),
onSuccess: () => {
state.reset();
navigate('/profile');
},
onError: (err: any) => {
alert(err.response?.data?.error || 'Ошибка при бронировании');
}
});
const paymentMutation = useMutation({
mutationFn: (data: any) => api.post('/payments/create', data).then(res => res.data)
});
// Today string for min date
const today = new Date().toISOString().split('T')[0];
const pubs = pubsRes?.data || [];
const tables = tablesRes?.data || [];
// Set default pub
React.useEffect(() => {
if (pubs.length > 0 && !state.pubId) {
state.setPubId(pubs[0].id);
}
}, [pubs, state.pubId]);
if (!isAuthenticated) {
return (
<div className="container" style={{ textAlign: 'center', paddingTop: 'var(--space-4xl)' }}>
<h2 style={{ marginBottom: 'var(--space-md)' }}>Требуется авторизация</h2>
<p className="text-secondary" style={{ marginBottom: 'var(--space-xl)' }}>Для бронирования стола необходимо войти в личный кабинет.</p>
<Button variant="primary" onClick={() => navigate('/')}>Вернуться на главную</Button>
</div>
);
}
const handleNextToDishes = () => {
if (!state.pubId || !state.date || !state.time || !state.numPeople || !state.tableId) {
alert('Пожалуйста, заполните все поля и выберите стол');
return;
}
setStep(2);
};
const handleConfirmReservation = async () => {
const payload = {
pubId: state.pubId,
tableId: state.tableId,
date: state.date,
time: state.time,
numPeople: state.numPeople,
dishes: state.includeDishes && state.dishes.length > 0
? state.dishes.map(d => ({ dishId: d.dishId, quantity: d.quantity }))
: undefined
};
if (payload.dishes && payload.dishes.length > 0) {
setPaymentMethod(user?.cardLastFour ? 'saved' : 'new');
setPaymentCardNumber('');
setPaymentCardExpiry('');
setPaymentCardCvv('');
setStep(3);
} else {
createReservationMutation.mutate(payload);
}
};
const handleMockPayment = async () => {
let cardLastFour: string;
let cardExpiry: string;
if (paymentMethod === 'saved') {
if (!user?.cardLastFour || !user?.cardExpiry) {
alert('Привязанная карта не найдена. Введите данные новой карты.');
return;
}
cardLastFour = user.cardLastFour;
cardExpiry = user.cardExpiry;
} else {
const cardDigits = paymentCardNumber.replace(/\D/g, '');
if (cardDigits.length !== 16) {
alert('Введите корректный номер карты (16 цифр)');
return;
}
if (!/^\d{2}\/\d{2}$/.test(paymentCardExpiry)) {
alert('Введите срок действия карты в формате ММ/ГГ');
return;
}
cardLastFour = cardDigits.slice(-4);
cardExpiry = paymentCardExpiry;
}
if (!/^\d{3,4}$/.test(paymentCardCvv)) {
alert('Введите корректный CVC/CVV');
return;
}
const payload = {
pubId: state.pubId,
tableId: state.tableId,
date: state.date,
time: state.time,
numPeople: state.numPeople,
dishes: state.dishes.map(d => ({ dishId: d.dishId, quantity: d.quantity }))
};
createReservationMutation.mutate(payload, {
onSuccess: async (res) => {
await paymentMutation.mutateAsync({
amount: Number(res.data.prepaid),
reservationId: res.data.id,
cardData: {
cardLastFour,
cardExpiry,
},
});
}
});
};
const formatCardNumber = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(.{4})/g, '$1 ').trim();
};
const formatCardExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};
const hasSavedCard = Boolean(user?.cardLastFour);
const totalCost = state.getTotalCost();
const prepaidAmount = Math.round(totalCost * 0.5);
if (pubsLoading) return <Loader />;
return (
<div className="container" style={{ maxWidth: '900px' }}>
<h1 className="text-center" style={{ marginBottom: 'var(--space-xl)', color: 'var(--color-gold-500)' }}>Бронирование стола</h1>
{/* Stepper indicator */}
<div className="flex justify-center items-center gap-md" style={{ marginBottom: 'var(--space-2xl)' }}>
<div className={`badge ${step >= 1 ? 'badge-gold' : 'badge-gray'}`}>1. Стол</div>
<div style={{ width: '40px', height: '2px', background: step >= 2 ? 'var(--color-gold-500)' : 'var(--color-border)' }} />
<div className={`badge ${step >= 2 ? 'badge-gold' : 'badge-gray'}`}>2. Предзаказ</div>
<div style={{ width: '40px', height: '2px', background: step >= 3 ? 'var(--color-gold-500)' : 'var(--color-border)' }} />
<div className={`badge ${step >= 3 ? 'badge-gold' : 'badge-gray'}`}>3. Оплата</div>
</div>
{step === 1 && (
<div className="card animate-fade-in flex-col gap-lg">
<div className="grid grid-2 gap-md">
<div>
<label className="input-label">Паб</label>
<select className="input" value={state.pubId || ''} onChange={(e) => state.setPubId(Number(e.target.value))}>
{pubs.map((p: any) => <option key={p.id} value={p.id}>{p.address}</option>)}
</select>
</div>
<Input label="Количество гостей" type="number" min="1" max="20" value={state.numPeople} onChange={(e) => state.setNumPeople(parseInt(e.target.value))} />
<Input label="Дата" type="date" min={today} value={state.date} onChange={(e) => state.setDate(e.target.value)} />
<Input label="Время" type="time" min="12:00" max="02:00" value={state.time} onChange={(e) => state.setTime(e.target.value)} />
</div>
<div className="divider" />
<div>
<div className="flex justify-between items-center mb-2">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Выберите стол</h3>
{tablesFetching && <span className="text-sm text-secondary animate-pulse">Обновление...</span>}
</div>
{(!state.date || !state.time) ? (
<div className="text-center p-8 bg-zinc-900 rounded border border-zinc-800 text-secondary">
Пожалуйста, выберите дату и время для просмотра свободных столов
</div>
) : tablesLoading ? (
<Loader />
) : (
<>
<FloorPlan tables={tables} selectedTableId={state.tableId} onSelectTable={state.setTableId} />
<div className="flex justify-center gap-lg" style={{ marginTop: 'var(--space-md)' }}>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-green-700)', borderRadius:'50%' }}/> Свободен</div>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-error-bg)', borderRadius:'50%' }}/> Занят / Не подходит</div>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-gold-500)', borderRadius:'50%' }}/> Выбран</div>
</div>
{state.tableId && (() => {
const sTable = tables.find((t: any) => t.id === state.tableId);
return sTable && sTable.capacity < state.numPeople && (
<div className="badge badge-red mt-4 ml-auto mr-auto block text-center" style={{ maxWidth: 300 }}>
Внимание: вместимость стола ({sTable.capacity}) меньше кол-ва гостей ({state.numPeople})
</div>
);
})()}
</>
)}
</div>
<div className="flex justify-end mt-4">
<Button variant="primary" size="lg" disabled={!state.tableId} onClick={handleNextToDishes}>
Далее
</Button>
</div>
</div>
)}
{step === 2 && (
<div className="animate-fade-in flex-col gap-lg">
<div className="card mb-4 flex justify-between items-center">
<div>
<p className="font-semibold">Предзаказ блюд <span className="badge badge-gray ml-2 text-xs">По желанию</span></p>
<p className="text-sm text-secondary">Оформляя предзаказ, вы экономите время ожидания. Требуется 50% предоплата за блюда.</p>
</div>
<div className="toggle-wrapper" onClick={() => state.setIncludeDishes(!state.includeDishes)}>
<div className={`toggle ${state.includeDishes ? 'active' : ''}`} />
</div>
</div>
{state.includeDishes && state.pubId && (
<div className="card">
<DishSelector pubId={state.pubId} />
</div>
)}
<div className="flex justify-between items-center mt-6 p-4 bg-zinc-900 rounded border border-zinc-800">
<div>
<Button variant="ghost" onClick={() => setStep(1)}>Назад</Button>
</div>
<div className="flex items-center gap-lg">
{state.includeDishes && state.dishes.length > 0 && (
<div style={{ textAlign: 'right' }}>
<p className="text-sm text-secondary">Итого (Предоплата):</p>
<p className="font-display font-bold text-lg" style={{ color: 'var(--color-gold-400)' }}>{prepaidAmount} </p>
</div>
)}
<Button variant="primary" size="lg" onClick={handleConfirmReservation} isLoading={createReservationMutation.isPending}>
{state.includeDishes && state.dishes.length > 0 ? 'Перейти к оплате' : 'Подтвердить бронь'}
</Button>
</div>
</div>
</div>
)}
{step === 3 && (
<div className="card animate-fade-in flex-col items-center justify-center p-8 gap-lg" style={{ minHeight: '400px' }}>
<h2 style={{ color: 'var(--color-gold-500)' }}>Оплата предзаказа</h2>
<p className="text-secondary text-center mb-6">Сумма к оплате (50%): <strong>{prepaidAmount} </strong></p>
<div className="p-6 bg-zinc-950 rounded border border-zinc-800 w-full max-w-sm mb-6">
<p className="text-sm text-muted text-center mb-4">MOCK ПЛАТЕЖНЫЙ ШЛЮЗ</p>
{hasSavedCard && (
<div className="flex flex-col gap-sm mb-4">
<label
className="flex items-center gap-sm p-3 rounded cursor-pointer"
style={{
background: paymentMethod === 'saved' ? 'var(--color-bg-elevated)' : 'transparent',
border: `1px solid ${paymentMethod === 'saved' ? 'var(--color-gold-500)' : 'var(--color-border)'}`,
}}
>
<input
type="radio"
name="paymentMethod"
checked={paymentMethod === 'saved'}
onChange={() => setPaymentMethod('saved')}
/>
<span className="text-sm">
💳 {user!.cardLastFour}
{user!.cardExpiry ? ` (до ${user!.cardExpiry})` : ''}
</span>
</label>
<label
className="flex items-center gap-sm p-3 rounded cursor-pointer"
style={{
background: paymentMethod === 'new' ? 'var(--color-bg-elevated)' : 'transparent',
border: `1px solid ${paymentMethod === 'new' ? 'var(--color-gold-500)' : 'var(--color-border)'}`,
}}
>
<input
type="radio"
name="paymentMethod"
checked={paymentMethod === 'new'}
onChange={() => setPaymentMethod('new')}
/>
<span className="text-sm">Оплатить другой картой</span>
</label>
</div>
)}
{paymentMethod === 'new' && (
<>
<Input
label="Номер карты"
placeholder="0000 0000 0000 0000"
value={paymentCardNumber}
onChange={(e) => setPaymentCardNumber(formatCardNumber(e.target.value))}
maxLength={19}
/>
<div className="grid grid-2 gap-md mt-4">
<Input
label="Срок действия"
placeholder="ММ/ГГ"
value={paymentCardExpiry}
onChange={(e) => setPaymentCardExpiry(formatCardExpiry(e.target.value))}
maxLength={5}
/>
<Input
label="CVC"
placeholder="***"
type="password"
value={paymentCardCvv}
onChange={(e) => setPaymentCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
maxLength={4}
/>
</div>
</>
)}
{paymentMethod === 'saved' && (
<Input
label="CVC"
placeholder="***"
type="password"
value={paymentCardCvv}
onChange={(e) => setPaymentCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
maxLength={4}
/>
)}
</div>
<div className="flex justify-center gap-md">
<Button variant="ghost" onClick={() => setStep(2)}>Отмена</Button>
<Button variant="primary" size="lg" onClick={handleMockPayment} isLoading={createReservationMutation.isPending || paymentMutation.isPending}>
Оплатить {prepaidAmount}
</Button>
</div>
</div>
)}
</div>
);
};
+213
View File
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { Button } from '@/components/Button';
import { Loader } from '@/components/Loader';
import { useBookingStore } from '@/features/booking/store';
import type { IPub, IDish } from '@harats/shared';
export const HomePage: React.FC = () => {
const navigate = useNavigate();
const setPubId = useBookingStore(state => state.setPubId);
const [selectedPubId, setSelectedPubId] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<string>('');
// Queries
const { data: pubsRes, isLoading: pubsLoading } = useQuery({
queryKey: ['pubs'],
queryFn: () => api.get('/pubs').then(res => res.data)
});
const { data: menuRes, isLoading: menuLoading } = useQuery({
queryKey: ['menu', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/menu`).then(res => res.data),
enabled: !!selectedPubId
});
const { data: lunchRes } = useQuery({
queryKey: ['lunch', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/lunch-menu`).then(res => res.data),
enabled: !!selectedPubId
});
const { data: billboardRes } = useQuery({
queryKey: ['billboard', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/billboard`).then(res => res.data),
enabled: !!selectedPubId
});
const pubs: IPub[] = pubsRes?.data || [];
const menu: Record<string, any[]> = menuRes?.data || {};
const lunch: any[] = lunchRes?.data || [];
const billboard: any[] = billboardRes?.data || [];
// Set default pub on load
React.useEffect(() => {
if (pubs.length > 0 && !selectedPubId) {
setSelectedPubId(pubs[0].id);
}
}, [pubs, selectedPubId]);
// Set default tab on menu load
React.useEffect(() => {
if (Object.keys(menu).length > 0 && !activeTab) {
setActiveTab(Object.keys(menu)[0]);
}
}, [menu, activeTab]);
const handleBooking = () => {
if (selectedPubId) {
setPubId(selectedPubId);
navigate('/booking');
}
};
if (pubsLoading) return <Loader />;
const selectedPub = pubs.find(p => p.id === selectedPubId);
return (
<div>
{/* Hero Section */}
<section style={{
position: 'relative',
padding: 'var(--space-4xl) 0',
background: 'linear-gradient(rgba(13,13,13,0.7), rgba(13,13,13,0.9)), url("https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&q=80") center/cover',
textAlign: 'center',
borderBottom: '1px solid var(--color-border)'
}}>
<div className="container">
<h1 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Добро пожаловать в Harat's</h1>
<p className="text-lg text-secondary" style={{ maxWidth: '600px', margin: '0 auto var(--space-xl)' }}>
Ирландский паб, где каждый вечер превращается в праздник.
Живая музыка, отличная компания и уютный хаос.
</p>
<div style={{
background: 'var(--color-bg-card)',
padding: 'var(--space-lg)',
borderRadius: 'var(--radius-xl)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-md)',
maxWidth: '800px',
margin: '0 auto',
border: '1px solid var(--color-border-light)'
}}>
<div style={{ flex: 1, textAlign: 'left' }}>
<label className="input-label" style={{ display: 'block', marginBottom: 'var(--space-xs)' }}>Выберите паб</label>
<select
className="input"
value={selectedPubId || ''}
onChange={(e) => setSelectedPubId(Number(e.target.value))}
style={{ background: 'var(--color-bg-input)' }}
>
{pubs.map(pub => (
<option key={pub.id} value={pub.id}>{pub.address}</option>
))}
</select>
</div>
{selectedPub && (
<div style={{ padding: '0 var(--space-md)', borderLeft: '1px solid var(--color-border)' }}>
<p className="text-secondary text-sm">Телефон</p>
<p style={{ fontWeight: 600 }}>{selectedPub.phone}</p>
</div>
)}
<Button variant="primary" size="lg" onClick={handleBooking}>Забронировать стол</Button>
</div>
</div>
</section>
<div className="container" style={{ marginTop: 'var(--space-3xl)' }}>
{/* Billboard */}
{billboard.length > 0 && (
<section style={{ marginBottom: 'var(--space-4xl)' }}>
<h2 style={{ marginBottom: 'var(--space-xl)' }}>Афиша</h2>
<div className="grid grid-3 gap-lg">
{billboard.map(event => (
<div key={event.id} className="card card-hover flex-col justify-between" style={{ minHeight: '160px' }}>
<div>
<div className="badge badge-gold mb-2">{new Date(event.datetime).toLocaleString('ru', { month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
<h4 style={{ marginTop: 'var(--space-sm)' }}>{event.title}</h4>
<p className="text-secondary text-sm">{event.artist}</p>
</div>
</div>
))}
</div>
</section>
)}
<div className="grid gap-4XL" style={{ gridTemplateColumns: 'minmax(0, 1fr) 300px', gap: 'var(--space-2xl)' }}>
{/* Main Menu */}
<section>
<h2 style={{ marginBottom: 'var(--space-xl)' }}>Меню</h2>
{menuLoading ? <Loader /> : Object.keys(menu).length > 0 ? (
<>
<div className="tabs" style={{ marginBottom: 'var(--space-xl)' }}>
{Object.keys(menu).map(cat => (
<div
key={cat}
className={`tab ${activeTab === cat ? 'tab-active' : ''}`}
onClick={() => setActiveTab(cat)}
>
{cat}
</div>
))}
</div>
<div className="grid grid-2 gap-md">
{menu[activeTab]?.map(item => (
<div key={item.dish.id} className="card" style={{ padding: 'var(--space-md)' }}>
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-xs)' }}>
<h4 style={{ fontSize: '1rem' }}>{item.dish.title}</h4>
<span style={{ color: 'var(--color-gold-500)', fontWeight: 600, whiteSpace: 'nowrap' }}>
{item.dish.price} ₽
</span>
</div>
<p className="text-sm text-secondary line-clamp-2">{item.dish.description}</p>
{item.dish.grams && <p className="text-xs text-muted" style={{ marginTop: 'var(--space-xs)' }}>{item.dish.grams} г</p>}
</div>
))}
</div>
</>
) : (
<p className="text-secondary">Меню пока не загружено.</p>
)}
</section>
{/* Business Lunch Sidebar */}
<aside>
<div className="card" style={{ borderTop: '4px solid var(--color-green-500)', background: 'var(--color-bg-elevated)', position: 'sticky', top: 'calc(var(--header-height) + var(--space-xl))' }}>
<div className="badge badge-green" style={{ marginBottom: 'var(--space-md)' }}>12:00 — 16:00</div>
<h3 style={{ marginBottom: 'var(--space-xs)' }}>Бизнес-ланч</h3>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-lg)' }}>Доступен только при заказе в пабе. Предзаказ невозможен.</p>
{lunch.length > 0 ? (
<div className="flex flex-col gap-sm">
{lunch.map(item => (
<div key={item.dish.id} style={{ borderBottom: '1px dashed var(--color-border-light)', paddingBottom: 'var(--space-sm)' }}>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{item.dish.title}</span>
<span className="text-sm" style={{ color: 'var(--color-gold-400)' }}>{item.dish.price} </span>
</div>
<p className="text-xs text-muted">{item.dish.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">Ланч-меню на эту неделю еще не опубликовано.</p>
)}
</div>
</aside>
</div>
</div>
</div>
);
};
+487
View File
@@ -0,0 +1,487 @@
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/services/api';
import { useAuthStore } from '@/features/auth/store';
import { Loader } from '@/components/Loader';
import { Button } from '@/components/Button';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/Input';
export const ProfilePage: React.FC = () => {
const { user, logout, updateUser } = useAuthStore();
const navigate = useNavigate();
const [profileName, setProfileName] = useState(user?.name || '');
const [profileSurname, setProfileSurname] = useState(user?.surname || '');
const [profilePhone, setProfilePhone] = useState(user?.phone || '');
const [profileEmail, setProfileEmail] = useState(user?.email || '');
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSuccess, setProfileSuccess] = useState<string | null>(null);
const [showCardForm, setShowCardForm] = useState(false);
const [showDeleteCard, setShowDeleteCard] = useState(false);
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [cardError, setCardError] = useState<string | null>(null);
React.useEffect(() => {
if (user) {
setProfileName(user.name || '');
setProfileSurname(user.surname || '');
setProfilePhone(user.phone || '');
setProfileEmail(user.email || '');
}
}, [user]);
const { data: resData, isLoading } = useQuery({
queryKey: ['my-reservations'],
queryFn: () => api.get('/reservations/my').then(res => res.data),
staleTime: 0,
});
const reservations = resData?.data || [];
const formatCardNumber = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(.{4})/g, '$1 ').trim();
};
const formatCardExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};
const resetCardForm = () => {
setCardNumber('');
setCardExpiry('');
setCardCvv('');
setCurrentPassword('');
setCardError(null);
setShowCardForm(false);
setShowDeleteCard(false);
};
const updateProfileMutation = useMutation({
mutationFn: async () => {
if (!profileName.trim()) {
throw new Error('Имя обязательно для заполнения');
}
if (!profilePhone.trim()) {
throw new Error('Телефон обязателен для заполнения');
}
const { data } = await api.put('/users/me', {
name: profileName.trim(),
surname: profileSurname.trim() || null,
phone: profilePhone.trim(),
email: profileEmail.trim() || null,
});
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({
name: res.data.name,
surname: res.data.surname,
phone: res.data.phone,
email: res.data.email,
});
setProfileError(null);
setProfileSuccess('Данные профиля сохранены');
}
},
onError: (err: any) => {
setProfileSuccess(null);
setProfileError(err.response?.data?.error || err.message || 'Не удалось сохранить профиль');
},
});
const linkCardMutation = useMutation({
mutationFn: async (payload: { cardNumber: string; cardExpiry: string; cardCvv: string; currentPassword?: string }) => {
const digits = payload.cardNumber.replace(/\D/g, '');
if (digits.length !== 16) {
throw new Error('Введите корректный номер карты (16 цифр)');
}
if (!/^\d{2}\/\d{2}$/.test(payload.cardExpiry)) {
throw new Error('Введите срок действия в формате ММ/ГГ');
}
if (!/^\d{3,4}$/.test(payload.cardCvv)) {
throw new Error('Введите корректный CVC/CVV');
}
const { data } = await api.put('/users/me/card', {
cardNumber: digits,
cardExpiry: payload.cardExpiry,
cardCvv: payload.cardCvv,
currentPassword: payload.currentPassword,
});
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({ cardLastFour: res.data.cardLastFour, cardExpiry: res.data.cardExpiry });
resetCardForm();
}
},
onError: (err: any) => {
setCardError(err.response?.data?.error || err.message || 'Не удалось привязать карту');
},
});
const unlinkCardMutation = useMutation({
mutationFn: async (password: string) => {
const { data } = await api.delete('/users/me/card', { data: { currentPassword: password } });
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({ cardLastFour: null, cardExpiry: null });
resetCardForm();
}
},
onError: (err: any) => {
setCardError(err.response?.data?.error || err.message || 'Не удалось удалить карту');
},
});
const handleCancel = async (id: number) => {
if (confirm('Вы уверены, что хотите отменить бронь?')) {
try {
await api.put(`/reservations/${id}/cancel`);
// Refresh page to see changes (in real app, invalidate query)
window.location.reload();
} catch (err) {
alert('Ошибка отмены брони');
}
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'PENDING': return <span className="badge badge-gold">Ожидает</span>;
case 'CONFIRMED': return <span className="badge badge-green">Подтверждена</span>;
case 'CANCELLED': return <span className="badge badge-red">Отменена</span>;
case 'COMPLETED': return <span className="badge badge-gray">Завершена</span>;
default: return <span className="badge">{status}</span>;
}
};
if (!user) return <Loader />;
return (
<div className="container">
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-2xl)' }}>
<h1 style={{ color: 'var(--color-gold-500)' }}>Личный кабинет</h1>
<Button variant="outline" onClick={() => { logout(); navigate('/'); }}>Выйти</Button>
</div>
<div className="grid" style={{ gridTemplateColumns: 'minmax(0, 1fr) 300px', gap: 'var(--space-2xl)' }}>
<div className="flex flex-col gap-lg">
<section>
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-md)' }}>
<h2>Мои бронирования</h2>
</div>
{isLoading ? <Loader /> : reservations.length > 0 ? (
<div className="flex flex-col gap-md">
{reservations.map((r: any) => (
<div key={r.id} className="card">
<div className="flex justify-between items-center" style={{ borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-sm)', marginBottom: 'var(--space-sm)' }}>
<div className="flex items-center gap-md">
<h4 style={{ margin: 0 }}>Бронь #{r.id}</h4>
{getStatusBadge(r.status)}
</div>
<span className="text-secondary">{new Date(r.createdAt).toLocaleDateString('ru')}</span>
</div>
<div className="grid grid-2 gap-md" style={{ marginBottom: 'var(--space-md)' }}>
<div>
<p className="text-sm text-secondary">Паб</p>
<p>{r.pub.name} ({r.pub.address})</p>
</div>
<div>
<p className="text-sm text-secondary">Дата и время</p>
<p>{new Date(r.date + 'T00:00:00').toLocaleDateString('ru')} в {r.time}</p>
</div>
<div>
<p className="text-sm text-secondary">Стол</p>
<p>{r.table.number} ({r.numPeople} чел.)</p>
</div>
<div>
<p className="text-sm text-secondary">Гости</p>
<p>{r.numPeople} персон</p>
</div>
</div>
{r.dishes && r.dishes.length > 0 && (
<div style={{ background: 'var(--color-bg-elevated)', padding: 'var(--space-md)', borderRadius: 'var(--radius-md)' }}>
<h5 style={{ marginBottom: 'var(--space-sm)' }}>Предзаказ блюд</h5>
<ul className="text-sm">
{r.dishes.map((d: any) => (
<li key={d.id} className="flex justify-between">
<span>{d.dish?.title} <span className="text-muted">x{d.quantity}</span></span>
<span>{d.priceAtOrder * d.quantity} </span>
</li>
))}
</ul>
<div className="flex justify-between" style={{ marginTop: 'var(--space-md)', paddingTop: 'var(--space-sm)', borderTop: '1px solid var(--color-border)' }}>
<span className="font-semibold">Итого за блюда (Предоплата 50%)</span>
<span className="font-semibold" style={{ color: 'var(--color-gold-400)' }}>{r.prepaid} </span>
</div>
</div>
)}
{['PENDING', 'CONFIRMED'].includes(r.status) && (
<div style={{ marginTop: 'var(--space-md)', textAlign: 'right' }}>
<Button variant="danger" size="sm" onClick={() => handleCancel(r.id)}>Отменить бронь</Button>
</div>
)}
</div>
))}
</div>
) : (
<div className="card text-center" style={{ padding: 'var(--space-3xl) var(--space-md)' }}>
<p className="text-secondary" style={{ marginBottom: 'var(--space-md)' }}>У вас еще нет бронирований</p>
<Button variant="primary" onClick={() => navigate('/booking')}>Забронировать сейчас</Button>
</div>
)}
</section>
</div>
<aside>
<div className="card flex flex-col gap-md">
<h3 style={{ borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-xs)' }}>Профиль</h3>
<Input
label="Имя"
value={profileName}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileName(e.target.value);
}}
/>
<Input
label="Фамилия"
value={profileSurname}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileSurname(e.target.value);
}}
/>
<Input
label="Телефон"
value={profilePhone}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfilePhone(e.target.value);
}}
/>
<Input
label="E-mail"
type="email"
value={profileEmail}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileEmail(e.target.value);
}}
/>
{profileError && (
<p className="text-sm" style={{ color: 'var(--color-error)' }}>{profileError}</p>
)}
{profileSuccess && (
<p className="text-sm" style={{ color: 'var(--color-green-400)' }}>{profileSuccess}</p>
)}
<Button
variant="primary"
size="sm"
isLoading={updateProfileMutation.isPending}
onClick={() => updateProfileMutation.mutate()}
>
Сохранить профиль
</Button>
<div style={{ marginTop: 'var(--space-md)' }}>
<h4 style={{ marginBottom: 'var(--space-xs)' }}>Привязанная карта</h4>
{user.cardLastFour ? (
<div className="flex items-center gap-sm" style={{ background: 'var(--color-bg-input)', padding: 'var(--space-sm)', borderRadius: 'var(--radius-sm)' }}>
<span>💳</span>
<span> {user.cardLastFour}{user.cardExpiry ? ` (до ${user.cardExpiry})` : ''}</span>
</div>
) : (
<p className="text-sm text-muted">Карта не привязана</p>
)}
{!showCardForm && !showDeleteCard && (
<div className="flex gap-sm" style={{ marginTop: 'var(--space-md)' }}>
{user.cardLastFour ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setCardError(null);
setShowDeleteCard(false);
setShowCardForm(true);
}}
>
Изменить
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
setCardError(null);
setShowCardForm(false);
setCurrentPassword('');
setShowDeleteCard(true);
}}
>
Удалить
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => {
setCardError(null);
setShowCardForm(true);
}}
>
Привязать карту
</Button>
)}
</div>
)}
{showDeleteCard && (
<div style={{ marginTop: 'var(--space-md)' }}>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>
Для удаления карты введите пароль аккаунта
</p>
<Input
label="Пароль аккаунта"
placeholder="Введите текущий пароль"
type="password"
value={currentPassword}
onChange={(e) => {
setCardError(null);
setCurrentPassword(e.target.value);
}}
/>
{cardError && (
<p className="text-sm" style={{ color: 'var(--color-error)', marginTop: 'var(--space-xs)' }}>{cardError}</p>
)}
<div className="flex gap-sm" style={{ marginTop: 'var(--space-sm)' }}>
<Button
variant="danger"
size="sm"
isLoading={unlinkCardMutation.isPending}
onClick={() => {
if (!currentPassword) {
setCardError('Введите пароль аккаунта');
return;
}
unlinkCardMutation.mutate(currentPassword);
}}
>
Подтвердить удаление
</Button>
<Button variant="ghost" size="sm" onClick={resetCardForm}>
Отмена
</Button>
</div>
</div>
)}
{showCardForm && (
<div style={{ marginTop: 'var(--space-md)' }}>
<Input
label="Номер карты"
placeholder="0000 0000 0000 0000"
value={cardNumber}
onChange={(e) => {
setCardError(null);
setCardNumber(formatCardNumber(e.target.value));
}}
maxLength={19}
/>
<div className="grid grid-2 gap-md" style={{ marginTop: 'var(--space-sm)' }}>
<Input
label="Срок действия"
placeholder="ММ/ГГ"
value={cardExpiry}
onChange={(e) => {
setCardError(null);
setCardExpiry(formatCardExpiry(e.target.value));
}}
maxLength={5}
/>
<Input
label="CVC/CVV"
placeholder="***"
type="password"
value={cardCvv}
onChange={(e) => {
setCardError(null);
setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4));
}}
maxLength={4}
/>
</div>
{user.cardLastFour && (
<Input
label="Пароль аккаунта (для изменения карты)"
placeholder="Введите текущий пароль"
type="password"
value={currentPassword}
onChange={(e) => {
setCardError(null);
setCurrentPassword(e.target.value);
}}
style={{ marginTop: 'var(--space-sm)' } as React.CSSProperties}
/>
)}
{cardError && (
<p className="text-sm" style={{ color: 'var(--color-error)', marginTop: 'var(--space-xs)' }}>{cardError}</p>
)}
<div className="flex gap-sm" style={{ marginTop: 'var(--space-sm)' }}>
<Button
variant="primary"
size="sm"
isLoading={linkCardMutation.isPending}
onClick={() => {
if (user.cardLastFour && !currentPassword) {
setCardError('Для изменения карты введите пароль аккаунта');
return;
}
linkCardMutation.mutate({
cardNumber,
cardExpiry,
cardCvv,
currentPassword: user.cardLastFour ? currentPassword : undefined,
});
}}
>
{user.cardLastFour ? 'Сохранить карту' : 'Привязать карту'}
</Button>
<Button variant="ghost" size="sm" onClick={resetCardForm}>
Отмена
</Button>
</div>
</div>
)}
</div>
</div>
</aside>
</div>
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { createBrowserRouter } from 'react-router-dom';
import { MainLayout } from '@/layouts/MainLayout';
import { HomePage } from '@/pages/HomePage';
import { BookingPage } from '@/pages/BookingPage';
import { ProfilePage } from '@/pages/ProfilePage';
import { AdminPage } from '@/pages/AdminPage';
export const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'booking', element: <BookingPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'admin', element: <AdminPage /> },
],
},
]);
+45
View File
@@ -0,0 +1,45 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
// Attach access token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 — try to refresh token
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
original.headers.Authorization = `Bearer ${data.data.accessToken}`;
return api(original);
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/';
}
}
}
return Promise.reject(error);
}
);
export default api;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noEmit": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["vite.config.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});