Initial commit: Harat's Booking monorepo with deploy setup
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -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;
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user