Initial commit: Harat's Booking monorepo with deploy setup

This commit is contained in:
Eugenius-Anonymous
2026-06-08 14:36:51 +07:00
commit c97f7fd1a8
89 changed files with 10536 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Exclude secrets and dev artifacts from Docker context
.env
server/.env
**/.env.local
**/node_modules
.git
.vscode
.cursor*
*.tar.gz
+21
View File
@@ -0,0 +1,21 @@
# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/harats_booking
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-in-production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Server
PORT=3001
NODE_ENV=development
# Client
VITE_API_URL=http://localhost:3001/api
# Email (Nodemailer)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
+41
View File
@@ -0,0 +1,41 @@
# ═══ Dependencies ═══
**/node_modules
# ═══ Build output ═══
client/dist
server/dist
packages/shared/dist
# ═══ Environment files (secrets) ═══
.env
server/.env
envs/prod/.env
!.env.example
!server/.env.example
!envs/prod/.env.example
# ═══ Runtime data ═══
**/logs
**/uploads
*.log
# ═══ IDE ═══
.idea
.cursor*
.vscode
*.swp
*.swo
*~
# ═══ OS ═══
.DS_Store
Thumbs.db
desktop.ini
# ═══ Deploy artifacts ═══
*.tar.gz
*.zip
releases/
# ═══ Misc ═══
*.tsbuildinfo
+73
View File
@@ -0,0 +1,73 @@
# ═══════════════════════════════════════════════
# Harat's Booking — Production Dockerfile
# ═══════════════════════════════════════════════
# Client is pre-built by deploy.ps1 (npm run build)
# This Dockerfile only handles server + copies dist
# ═══════════════════════════════════════════════
# ── Stage 1: Server Dependencies ──
FROM node:20-alpine AS server-deps
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev
# ── Stage 2: Prisma Generate ──
FROM node:20-alpine AS prisma-gen
WORKDIR /app
COPY --from=server-deps /app/node_modules ./node_modules
COPY server/prisma ./prisma
COPY server/package.json ./
RUN npx prisma generate
# ── Stage 3: Final Image ──
FROM node:20-alpine
WORKDIR /app
# Runtime deps + pm2
RUN apk add --no-cache curl \
&& npm install -g pm2@latest \
&& pm2 install pm2-logrotate \
&& pm2 set pm2-logrotate:max_size 10M \
&& pm2 set pm2-logrotate:retain 7 \
&& pm2 set pm2-logrotate:compress true
# Don't run as root
RUN addgroup -S harats && adduser -S harats -G harats \
&& mkdir -p /data/logs \
&& chown -R harats:harats /data
# Copy production node_modules (with generated Prisma client)
COPY --chown=harats:harats --from=prisma-gen /app/node_modules ./node_modules
# Copy server compiled source
COPY --chown=harats:harats server/dist ./dist
COPY --chown=harats:harats server/prisma ./prisma
# Copy shared package (workspace dependency)
COPY --chown=harats:harats packages/shared/ ./node_modules/@harats/shared/
# Copy pre-built client dist
COPY --chown=harats:harats client/dist ./public
# Copy ecosystem config
COPY --chown=harats:harats ecosystem.config.cjs ./ecosystem.config.cjs
# Copy version.json
COPY --chown=harats:harats version.json ./version.json
# Version info (injected at build time)
ARG BUILD_VERSION=0.0.0
ARG BUILD_DATE=unknown
RUN echo "{\"version\":\"${BUILD_VERSION}\",\"build\":\"$(echo $BUILD_DATE | cut -c1-16)\",\"buildDate\":\"${BUILD_DATE}\"}" > version.json
USER harats
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:3001/api/health || exit 1
CMD ["pm2-runtime", "ecosystem.config.cjs"]
+73
View File
@@ -0,0 +1,73 @@
# Harat's Pub Booking System
Полностековое веб-приложение для бронирования столов и оформления предзаказов в сети ирландских пабов Harat's.
## 🛠 Технологический стек
- **Архитектура:** Monorepo (npm workspaces)
- **Frontend (Client):** React 19, TypeScript, Vite, Zustand, TanStack Query, React-Konva (для интерактивной карты заведения)
- **Backend (Server):** Node.js, Express, TypeScript, Zod (валидация)
- **База данных:** PostgreSQL 16
- **ORM:** Prisma
- **Авторизация:** JWT (Access + Refresh tokens), хэширование bcrypt
---
## 🚀 Инструкция по локальному запуску
Для развертывания проекта на локальной машине с операционной системой (например, под управлением Ubuntu или Windows) потребуется установленный **Node.js (v18+)** и сервер **PostgreSQL**.
### Шаг 1. Установка зависимостей
Откройте терминал в корневой директории проекта и установите все npm-пакеты:
```bash
npm install
```
### Шаг 2. Настройка базы данных
Убедитесь, что у вас запущен локальный или удаленный PostgreSQL.
Откройте файл `server/.env` и пропишите актуальные реквизиты доступа к вашей СУБД:
```env
# Формат: postgresql://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]
DATABASE_URL=postgresql://postgres:password@localhost:5432/harats_booking
```
### Шаг 3. Инициализация базы данных
Если вы запускаете проект впервые на чистой базе, вам нужно создать таблицы и заполнить их тестовыми данными. Для этого выполните скрипт инициализации:
```bash
npm run db:init
```
> **Что делает эта команда?**
> - Применяет структуру таблиц Prisma к вашей базе данных (`npm run db:migrate`).
> - Заполняет БД категориями, меню (40+ позиций), планом столов, актуальными бизнес-ланчами и демо-пользователями (`npm run db:seed`).
### Шаг 4. Запуск серверов разработчика
Для одновременного запуска Backend-сервера и Frontend-приложения используйте всего одну команду в корне:
```bash
npm run dev
```
> **Доступ:**
> - Клиентское приложение: http://localhost:5173
> - API Сервер: http://localhost:3001
---
## 🔑 Тестовые аккаунты
База предзаполняется двумя типами пользователей для тестирования.
Пароль для обоих аккаунтов одинаковый: `admin123` и `user123` (соответственно роли).
| Роль | Телефон | Пароль | Доступ |
| :--- | :--- | :--- | :--- |
| **Администратор** | `+79001234567` | `admin123` | Права просмотра и редактирования в панели `/admin` |
| **Пользователь** | `+79009876543` | `user123` | Тестовый аккаунт гостя для проверки бронирования |
---
## 🏗 Дополнительные команды
Все скрипты выполняются из корневой директории проекта:
- `npm run build` — Собрать проект для Production (сначала собирается пакет `shared`, затем `client` и `server`).
- `npm run dev:client` — Запустить только React-приложение фронтенда.
- `npm run dev:server` — Запустить только Express-сервер бэкенда.
- `npm run db:reset` — Полностью сбросить и удалить старую базу данных, пересоздав её заново.
- `npm run db:studio` (из папки `server`) — Открыть веб-интерфейс Prisma для прямого управления записями БД в браузере.
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Забронируйте стол и блюда в Harat's Irish Pub — сети ирландских пабов. Выбирайте место, предзаказывайте блюда, оплачивайте онлайн." />
<title>Harat's Irish Pub — Бронирование</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@harats/client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@harats/shared": "*",
"axios": "^1.7.0",
"konva": "^9.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-konva": "^19.0.0",
"react-router-dom": "^7.1.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.62.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './router';
import { useAuthStore } from './features/auth/store';
import { Loader } from './components/Loader';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
export const App: React.FC = () => {
const { loadUser, isLoading } = useAuthStore();
useEffect(() => {
loadUser();
}, [loadUser]);
if (isLoading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Loader />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
};
export default App;
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'gold' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
isLoading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, variant = 'primary', size = 'md', fullWidth, isLoading, className = '', disabled, ...props }, ref) => {
let btnClass = 'btn';
// Variant
if (variant === 'primary') btnClass += ' btn-primary';
else if (variant === 'gold') btnClass += ' btn-gold';
else if (variant === 'outline') btnClass += ' btn-outline';
else if (variant === 'ghost') btnClass += ' btn-ghost';
else if (variant === 'danger') btnClass += ' btn-danger';
// Size
if (size === 'sm') btnClass += ' btn-sm';
else if (size === 'lg') btnClass += ' btn-lg';
// Full width
if (fullWidth) btnClass += ' btn-full';
return (
<button
ref={ref}
className={`${btnClass} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<svg className="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30" strokeLinecap="round" opacity="0.3"></circle>
<path d="M12 2A10 10 0 0 1 22 12" stroke="currentColor" strokeWidth="4" strokeLinecap="round"></path>
</svg>
) : children}
</button>
);
}
);
Button.displayName = 'Button';
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
export const Footer: React.FC = () => {
return (
<footer style={{
background: 'var(--color-bg-secondary)',
borderTop: '1px solid var(--color-border)',
padding: 'var(--space-3xl) 0',
marginTop: 'auto'
}}>
<div className="container">
<div className="grid grid-3 gap-xl">
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Harat's Irish Pub</h4>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>ООО «Хэрат'с»</p>
<p className="text-sm text-secondary">
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного отдыхать душой.
</p>
</div>
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Контакты</h4>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
<li>📞 8 (800) 775-03-39 (Customer Service)</li>
<li> <a href="mailto:info@harats.com" style={{ color: 'var(--color-green-400)' }}>info@harats.com</a></li>
<li style={{ marginTop: 'var(--space-sm)' }}>
📍 Главный офис:<br />
664033, Россия, Иркутская область, Иркутск, ул. Майская, 20
</li>
</ul>
</div>
<div>
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Информация</h4>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>О компании</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Франшиза</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Поставщикам</a></li>
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Реклама</a></li>
</ul>
</div>
</div>
<div className="divider" style={{ borderColor: 'var(--color-border-light)' }}></div>
<div className="flex justify-between items-center text-xs text-muted">
<p>© {new Date().getFullYear()} Harat's Irish Pub. Все права защищены.</p>
<p>
<span style={{ display: 'inline-block', padding: '2px 8px', border: '1px solid currentColor', borderRadius: '4px', marginRight: '8px' }}>18+</span>
Чрезмерное употребление алкоголя вредит вашему здоровью.
</p>
</div>
</div>
</footer>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth/store';
import { Button } from './Button';
import { LoginModal } from '@/features/auth/components/LoginModal';
import { RegisterModal } from '@/features/auth/components/RegisterModal';
export const Header: React.FC = () => {
const { isAuthenticated, user } = useAuthStore();
const navigate = useNavigate();
const [isLoginOpen, setLoginOpen] = useState(false);
const [isRegisterOpen, setRegisterOpen] = useState(false);
const openRegister = () => {
setLoginOpen(false);
setRegisterOpen(true);
};
const openLogin = () => {
setRegisterOpen(false);
setLoginOpen(true);
};
return (
<>
<header style={{
position: 'sticky',
top: 0,
zIndex: 50,
height: 'var(--header-height)',
background: 'rgba(13, 13, 13, 0.85)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center'
}}>
<div className="container flex items-center justify-between">
<div style={{ flex: 1 }}>
{isAuthenticated ? (
<Button variant="ghost" onClick={() => navigate('/profile')}>
Личный кабинет
</Button>
) : (
<Button variant="ghost" onClick={() => setLoginOpen(true)}>
Вход / Регистрация
</Button>
)}
</div>
<div style={{ flex: 1, textAlign: 'center' }}>
<Link to="/" style={{ display: 'inline-block' }}>
<span className="text-display" style={{ color: 'var(--color-gold-500)', fontSize: '1.5rem', letterSpacing: '0.05em' }}>
Harat's
</span>
<span className="text-display" style={{ display: 'block', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.2em', color: 'var(--color-green-500)' }}>
Irish Pub
</span>
</Link>
</div>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)' }}>
{user?.role === 'ADMIN' && (
<Button variant="outline" onClick={() => navigate('/admin')}>Админ</Button>
)}
</div>
</div>
</header>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setLoginOpen(false)}
onSwitchToRegister={openRegister}
/>
<RegisterModal
isOpen={isRegisterOpen}
onClose={() => setRegisterOpen(false)}
onSwitchToLogin={openLogin}
/>
</>
);
};
+27
View File
@@ -0,0 +1,27 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', id, ...props }, ref) => {
const inputId = id || Math.random().toString(36).substr(2, 9);
return (
<div className={`input-group ${className}`}>
{label && <label htmlFor={inputId} className="input-label">{label}</label>}
<input
id={inputId}
ref={ref}
className={`input ${error ? 'input-error' : ''}`}
{...props}
/>
{error && <span className="error-text">{error}</span>}
</div>
);
}
);
Input.displayName = 'Input';
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export const Loader: React.FC = () => {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: 'var(--space-2xl)' }}>
<svg className="animate-spin" width="48" height="48" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="var(--color-bg-elevated)" strokeWidth="4"></circle>
<path d="M12 2A10 10 0 0 1 22 12" stroke="var(--color-gold-500)" strokeWidth="4" strokeLinecap="round"></path>
</svg>
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
wide?: boolean;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer, wide }) => {
if (!isOpen) return null;
return (
<div className="overlay" onClick={(e) => { if(e.target === e.currentTarget) onClose(); }}>
<div className={`modal ${wide ? 'modal-wide' : ''}`}>
<div className="modal-header">
<h3 className="m-0">{title}</h3>
<button className="modal-close" onClick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Modal } from '@/components/Modal';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useAuthStore } from '../store';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToRegister: () => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToRegister }) => {
const login = useAuthStore(state => state.login);
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(phone, password);
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка входа');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Вход в личный кабинет">
<form onSubmit={handleSubmit} className="flex flex-col gap-md">
{error && <div className="badge badge-red" style={{ padding: '8px 12px' }}>{error}</div>}
<Input
label="Номер телефона"
type="tel"
placeholder="+7 (999) 000-00-00"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
<Input
label="Пароль"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit" variant="primary" fullWidth size="lg" isLoading={isLoading} style={{ marginTop: 'var(--space-sm)' }}>
Войти
</Button>
<p className="text-center text-sm text-secondary mt-4">
Нет аккаунта?{' '}
<button type="button" onClick={onSwitchToRegister} style={{ color: 'var(--color-gold-400)', fontWeight: 600 }}>
Зарегистрироваться
</button>
</p>
</form>
</Modal>
);
};
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Modal } from '@/components/Modal';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useAuthStore } from '../store';
interface RegisterModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToLogin: () => void;
}
export const RegisterModal: React.FC<RegisterModalProps> = ({ isOpen, onClose, onSwitchToLogin }) => {
const register = useAuthStore(state => state.register);
const [formData, setFormData] = useState({
phone: '',
name: '',
surname: '',
email: '',
password: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await register(formData);
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка регистрации');
} finally {
setIsLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Регистрация">
<form onSubmit={handleSubmit} className="flex flex-col gap-md">
{error && <div className="badge badge-red" style={{ padding: '8px 12px' }}>{error}</div>}
<Input
label="Номер телефона *"
name="phone"
type="tel"
placeholder="+7 (999) 000-00-00"
value={formData.phone}
onChange={handleChange}
required
/>
<div className="grid grid-2 gap-md">
<Input
label="Имя *"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
<Input
label="Фамилия"
name="surname"
value={formData.surname}
onChange={handleChange}
/>
</div>
<Input
label="E-mail (для чеков)"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<Input
label="Пароль *"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
required
/>
<Button type="submit" variant="primary" fullWidth size="lg" isLoading={isLoading} style={{ marginTop: 'var(--space-sm)' }}>
Создать аккаунт
</Button>
<p className="text-center text-sm text-secondary mt-4">
Уже есть аккаунт?{' '}
<button type="button" onClick={onSwitchToLogin} style={{ color: 'var(--color-gold-400)', fontWeight: 600 }}>
Войти
</button>
</p>
</form>
</Modal>
);
};
+75
View File
@@ -0,0 +1,75 @@
import { create } from 'zustand';
import type { IUser } from '@harats/shared';
import api from '@/services/api';
interface AuthState {
user: IUser | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (phone: string, password: string) => Promise<void>;
register: (data: { phone: string; password: string; name: string; surname?: string; email?: string }) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
updateUser: (data: Partial<IUser>) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,
login: async (phone, password) => {
const { data } = await api.post('/auth/login', { phone, password });
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
set({ user: data.data.user, isAuthenticated: true });
} else {
throw new Error(data.error);
}
},
register: async (payload) => {
const { data } = await api.post('/auth/register', payload);
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
set({ user: data.data.user, isAuthenticated: true });
} else {
throw new Error(data.error);
}
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
},
loadUser: async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
set({ isLoading: false });
return;
}
try {
const { data } = await api.get('/users/me');
if (data.success) {
set({ user: data.data, isAuthenticated: true, isLoading: false });
} else {
set({ isLoading: false });
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ isLoading: false });
}
},
updateUser: (data) => {
set((state) => ({
user: state.user ? { ...state.user, ...data } : null,
}));
},
}));
@@ -0,0 +1,88 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/services/api';
import { useBookingStore } from '../store';
import { Loader } from '@/components/Loader';
import { Button } from '@/components/Button';
import type { IDish } from '@harats/shared';
interface DishSelectorProps {
pubId: number;
}
export const DishSelector: React.FC<DishSelectorProps> = ({ pubId }) => {
const { addDish, removeDish, updateDishQty, dishes: cartDishes } = useBookingStore();
const { data: menuRes, isLoading } = useQuery({
queryKey: ['menu', pubId],
queryFn: () => api.get(`/pubs/${pubId}/menu`).then(res => res.data)
});
if (isLoading) return <Loader />;
const menu: Record<string, any[]> = menuRes?.data || {};
return (
<div className="flex flex-col gap-xl">
{Object.entries(menu).map(([category, items]) => (
<div key={category}>
<h4 style={{ marginBottom: 'var(--space-md)', color: 'var(--color-gold-500)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-xs)' }}>
{category}
</h4>
<div className="grid grid-2 gap-md">
{items.map((item: any) => {
const dish: IDish = item.dish;
const inCart = cartDishes.find(d => d.dishId === dish.id);
const isAvailable = dish.availability === 'AVAILABLE';
return (
<div key={dish.id} className="card flex-col justify-between" style={{ padding: 'var(--space-md)', opacity: isAvailable ? 1 : 0.5 }}>
<div>
<div className="flex justify-between items-start mb-2">
<span className="font-semibold">{dish.title}</span>
<span style={{ color: 'var(--color-gold-400)' }}>{dish.price} </span>
</div>
<p className="text-sm text-secondary line-clamp-2" style={{ minHeight: '40px' }}>{dish.description}</p>
</div>
<div className="flex justify-end items-center mt-4" style={{ height: '36px' }}>
{inCart ? (
<div className="flex items-center gap-md">
<Button
variant="outline"
size="sm"
style={{ minWidth: '36px', minHeight: '36px', padding: 0 }}
onClick={() => updateDishQty(dish.id, inCart.quantity - 1)}
>
-
</Button>
<span>{inCart.quantity}</span>
<Button
variant="outline"
size="sm"
style={{ minWidth: '36px', minHeight: '36px', padding: 0 }}
onClick={() => updateDishQty(dish.id, inCart.quantity + 1)}
>
+
</Button>
</div>
) : (
<Button
variant="primary"
size="sm"
disabled={!isAvailable}
onClick={() => addDish({ dishId: dish.id, title: dish.title, price: dish.price, quantity: 1 })}
>
Добавить
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,140 @@
import React, { useRef, useEffect, useState } from 'react';
import { Stage, Layer, Rect, Circle, Text, Group } from 'react-konva';
import type { ITableAvailability } from '@harats/shared';
interface FloorPlanProps {
tables: ITableAvailability[];
selectedTableId: number | null;
onSelectTable: (id: number) => void;
}
export const FloorPlan: React.FC<FloorPlanProps> = ({ tables, selectedTableId, onSelectTable }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
// Responsive canvas sizing
useEffect(() => {
const resize = () => {
if (containerRef.current) {
const { width } = containerRef.current.getBoundingClientRect();
// Maintain aspect ratio or fix height, let's just make it scale responsive
// Scale down based on 800px base width
setDimensions({ width, height: 500 * (width / 800) });
}
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, []);
const scale = dimensions.width / 800; // 800 is our base coordinate system width
return (
<div ref={containerRef} style={{ width: '100%', background: 'var(--color-bg-elevated)', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<Stage width={dimensions.width} height={dimensions.height}>
<Layer>
{/* Background zones (Optional decorative) */}
<Rect x={0} y={0} width={800 * scale} height={320 * scale} fill="transparent" />
<Rect x={0} y={320 * scale} width={800 * scale} height={180 * scale} fill="rgba(200, 169, 81, 0.03)" />
<Text x={20 * scale} y={330 * scale} text="VIP & Terrace" fontSize={14 * scale} fill="var(--color-text-muted)" />
<Text x={560 * scale} y={20 * scale} text="Bar Area" fontSize={14 * scale} fill="var(--color-text-muted)" />
{tables.map(table => {
const isSelected = selectedTableId === table.id;
// Colors
let fillColor = 'var(--color-bg-card)';
let strokeColor = 'var(--color-border)';
if (isSelected) {
fillColor = 'var(--color-gold-500)';
strokeColor = 'var(--color-gold-600)';
} else if (table.isAvailable) {
fillColor = 'var(--color-green-700)';
strokeColor = 'var(--color-green-600)';
} else {
fillColor = 'var(--color-error-bg)';
strokeColor = 'var(--color-error)';
}
const x = table.posX * scale;
const y = table.posY * scale;
const w = table.width * scale;
const h = table.height * scale;
return (
<Group
key={table.id}
x={x}
y={y}
onClick={() => table.isAvailable && onSelectTable(table.id)}
onTap={() => table.isAvailable && onSelectTable(table.id)}
onMouseEnter={(e) => {
if (table.isAvailable) {
const stage = e.target.getStage();
if(stage) stage.container().style.cursor = 'pointer';
}
}}
onMouseLeave={(e) => {
const stage = e.target.getStage();
if(stage) stage.container().style.cursor = 'default';
}}
>
{table.shape === 'rect' ? (
<Rect
width={w}
height={h}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
cornerRadius={8 * scale}
opacity={table.isAvailable ? 1 : 0.6}
shadowColor="black"
shadowBlur={4}
shadowOpacity={0.2}
shadowOffset={{ x: 0, y: 2 }}
/>
) : (
<Circle
radius={w / 2}
x={w / 2}
y={w / 2}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
opacity={table.isAvailable ? 1 : 0.6}
shadowColor="black"
shadowBlur={4}
shadowOpacity={0.2}
shadowOffset={{ x: 0, y: 2 }}
/>
)}
<Text
text={table.number.toString()}
width={w}
height={h}
align="center"
verticalAlign="middle"
fill="white"
fontSize={14 * scale}
fontStyle="bold"
/>
<Text
text={`${table.capacity} чел`}
width={w}
height={h}
y={18 * scale}
align="center"
verticalAlign="middle"
fill="rgba(255,255,255,0.7)"
fontSize={10 * scale}
/>
</Group>
);
})}
</Layer>
</Stage>
</div>
);
};
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand';
interface BookingDish {
dishId: number;
title: string;
price: number;
quantity: number;
}
interface BookingState {
pubId: number | null;
tableId: number | null;
date: string;
time: string;
numPeople: number;
includeDishes: boolean;
dishes: BookingDish[];
setPubId: (id: number) => void;
setTableId: (id: number | null) => void;
setDate: (date: string) => void;
setTime: (time: string) => void;
setNumPeople: (n: number) => void;
setIncludeDishes: (v: boolean) => void;
addDish: (dish: BookingDish) => void;
removeDish: (dishId: number) => void;
updateDishQty: (dishId: number, qty: number) => void;
getTotalCost: () => number;
reset: () => void;
}
const initialState = {
pubId: null as number | null,
tableId: null as number | null,
date: '',
time: '',
numPeople: 2,
includeDishes: false,
dishes: [] as BookingDish[],
};
export const useBookingStore = create<BookingState>((set, get) => ({
...initialState,
setPubId: (id) => set({ pubId: id, tableId: null }),
setTableId: (id) => set({ tableId: id }),
setDate: (date) => set({ date, tableId: null }),
setTime: (time) => set({ time, tableId: null }),
setNumPeople: (n) => set({ numPeople: n }),
setIncludeDishes: (v) => set({ includeDishes: v, dishes: v ? get().dishes : [] }),
addDish: (dish) => {
const existing = get().dishes.find(d => d.dishId === dish.dishId);
if (existing) {
set({
dishes: get().dishes.map(d =>
d.dishId === dish.dishId ? { ...d, quantity: d.quantity + 1 } : d
),
});
} else {
set({ dishes: [...get().dishes, dish] });
}
},
removeDish: (dishId) => {
set({ dishes: get().dishes.filter(d => d.dishId !== dishId) });
},
updateDishQty: (dishId, qty) => {
if (qty <= 0) {
set({ dishes: get().dishes.filter(d => d.dishId !== dishId) });
} else {
set({
dishes: get().dishes.map(d =>
d.dishId === dishId ? { ...d, quantity: qty } : d
),
});
}
},
getTotalCost: () => {
return get().dishes.reduce((sum, d) => sum + d.price * d.quantity, 0);
},
reset: () => set(initialState),
}));
+709
View File
@@ -0,0 +1,709 @@
/* ═══════════════════════════════════════════
Harat's Irish Pub — Design System
Dark pub atmosphere, green & gold accents
═══════════════════════════════════════════ */
/* ─── Reset ──────────────────────────────── */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-body);
background: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
ul, ol { list-style: none; }
img { max-width: 100%; display: block; }
input, select, textarea { font: inherit; }
/* ─── Design Tokens ──────────────────────── */
:root {
/* Brand Colors */
--color-green-900: #0a2e1a;
--color-green-800: #134d2e;
--color-green-700: #1a6b3f;
--color-green-600: #228b52;
--color-green-500: #2eaa66;
--color-green-400: #4fc882;
--color-green-300: #7eddaa;
--color-gold-600: #a88a30;
--color-gold-500: #c8a951;
--color-gold-400: #d4bc6a;
--color-gold-300: #e0cf8a;
--color-gold-200: #eee0b0;
/* Surfaces */
--color-bg-primary: #0d0d0d;
--color-bg-secondary: #151515;
--color-bg-card: #1a1a1a;
--color-bg-elevated: #222222;
--color-bg-input: #1e1e1e;
--color-bg-hover: #2a2a2a;
/* Text */
--color-text-primary: #f0efe8;
--color-text-secondary: #a09e94;
--color-text-muted: #6b6960;
--color-text-inverse: #0d0d0d;
/* Borders */
--color-border: #2a2a2a;
--color-border-light: #333333;
--color-border-focus: var(--color-gold-500);
/* Semantic */
--color-error: #e54d4d;
--color-error-bg: #2a1515;
--color-success: #2eaa66;
--color-success-bg: #152a1a;
--color-warning: #d4a74a;
--color-warning-bg: #2a2415;
/* Typography */
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Spacing */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
--space-4xl: 96px;
/* Radius */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.8);
--shadow-glow-green: 0 0 20px rgba(46, 170, 102, 0.15);
--shadow-glow-gold: 0 0 20px rgba(200, 169, 81, 0.15);
/* Transitions */
--transition-fast: 120ms ease;
--transition-base: 200ms ease;
--transition-slow: 350ms ease;
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Layout */
--header-height: 72px;
--max-width: 1280px;
--sidebar-width: 280px;
}
/* ─── Scrollbar ──────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* ─── Typography ─────────────────────────── */
.text-display {
font-family: var(--font-display);
font-weight: 700;
line-height: 1.1;
}
h1 { font-family: var(--font-display); font-size: 2.5rem; font-weight: 700; line-height: 1.1; }
h2 { font-family: var(--font-display); font-size: 2rem; font-weight: 600; line-height: 1.2; }
h3 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 600; line-height: 1.3; }
h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-lg { font-size: 1.125rem; }
/* ─── Button Base ────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 12px 24px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 0.9375rem;
transition: all var(--transition-base);
white-space: nowrap;
min-height: 44px;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: white;
opacity: 0;
transition: opacity var(--transition-fast);
}
.btn:active::after {
opacity: 0.1;
}
.btn-primary {
background: linear-gradient(135deg, var(--color-green-600), var(--color-green-500));
color: white;
box-shadow: var(--shadow-sm), var(--shadow-glow-green);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-green-500), var(--color-green-400));
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow-green);
}
.btn-gold {
background: linear-gradient(135deg, var(--color-gold-600), var(--color-gold-500));
color: var(--color-text-inverse);
box-shadow: var(--shadow-sm), var(--shadow-glow-gold);
}
.btn-gold:hover {
background: linear-gradient(135deg, var(--color-gold-500), var(--color-gold-400));
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow-gold);
}
.btn-outline {
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border-light);
}
.btn-outline:hover {
border-color: var(--color-gold-500);
color: var(--color-gold-400);
background: rgba(200, 169, 81, 0.05);
}
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
}
.btn-ghost:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.btn-danger {
background: var(--color-error);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-sm {
padding: 8px 16px;
font-size: 0.8125rem;
min-height: 36px;
}
.btn-lg {
padding: 16px 32px;
font-size: 1.0625rem;
min-height: 52px;
border-radius: var(--radius-lg);
}
.btn-full {
width: 100%;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
}
/* ─── Input ──────────────────────────────── */
.input-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.input-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
}
.input {
width: 100%;
padding: 12px 16px;
background: var(--color-bg-input);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.9375rem;
transition: border-color var(--transition-base), box-shadow var(--transition-base);
min-height: 44px;
}
.input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(200, 169, 81, 0.1);
}
.input::placeholder {
color: var(--color-text-muted);
}
.input-error {
border-color: var(--color-error);
}
.error-text {
font-size: 0.75rem;
color: var(--color-error);
}
select.input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23a09e94' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 40px;
}
/* ─── Card ───────────────────────────────── */
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.card-hover:hover {
border-color: var(--color-border-light);
box-shadow: var(--shadow-md);
}
.card-glass {
background: rgba(26, 26, 26, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* ─── Badge ──────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-full);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-green {
background: var(--color-success-bg);
color: var(--color-success);
}
.badge-red {
background: var(--color-error-bg);
color: var(--color-error);
}
.badge-gold {
background: rgba(200, 169, 81, 0.12);
color: var(--color-gold-400);
}
.badge-gray {
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
}
/* ─── Modal / Overlay ────────────────────── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
animation: fadeIn var(--transition-base) ease;
}
.modal {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: slideUp var(--transition-slow) ease;
}
.modal-wide {
max-width: 800px;
}
.modal-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
padding: var(--space-lg);
}
.modal-footer {
padding: var(--space-lg);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.modal-close {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
.modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
/* ─── Tabs ───────────────────────────────── */
.tabs {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
transition: all var(--transition-base);
white-space: nowrap;
cursor: pointer;
}
.tab:hover {
color: var(--color-text-primary);
}
.tab-active {
color: var(--color-gold-400);
border-bottom-color: var(--color-gold-500);
}
/* ─── Table (data) ───────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: var(--space-sm) var(--space-md);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
}
.data-table td {
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.data-table tr:hover td {
background: var(--color-bg-hover);
}
/* ─── Checkbox / Toggle ──────────────────── */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
}
.checkbox {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid var(--color-border-light);
border-radius: var(--radius-xs);
background: var(--color-bg-input);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
}
.checkbox:checked {
background: var(--color-green-600);
border-color: var(--color-green-600);
}
.checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 12px;
font-weight: 700;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
background: var(--color-bg-elevated);
border-radius: var(--radius-full);
cursor: pointer;
transition: background var(--transition-base);
border: 1px solid var(--color-border);
}
.toggle.active {
background: var(--color-green-600);
border-color: var(--color-green-600);
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
transition: transform var(--transition-base);
}
.toggle.active::after {
transform: translateX(20px);
}
/* ─── Skeleton Loader ────────────────────── */
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-elevated) 25%,
var(--color-bg-hover) 50%,
var(--color-bg-elevated) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
/* ─── Toast / Notification ───────────────── */
.toast-container {
position: fixed;
top: calc(var(--header-height) + var(--space-md));
right: var(--space-md);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.toast {
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-lg);
font-size: 0.875rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideInRight var(--transition-slow) ease;
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 280px;
}
.toast-success {
background: var(--color-success-bg);
border: 1px solid var(--color-success);
color: var(--color-success);
}
.toast-error {
background: var(--color-error-bg);
border: 1px solid var(--color-error);
color: var(--color-error);
}
/* ─── Layout Utilities ───────────────────── */
.container {
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-lg);
}
.page {
min-height: calc(100vh - var(--header-height));
padding-top: var(--space-2xl);
padding-bottom: var(--space-3xl);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.gap-lg { gap: var(--space-lg); }
.gap-xl { gap: var(--space-xl); }
.grid {
display: grid;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr;
}
}
/* ─── Divider ────────────────────────────── */
.divider {
height: 1px;
background: var(--color-border);
border: none;
margin: var(--space-lg) 0;
}
/* ─── Animations ─────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in { animation: fadeIn var(--transition-base) ease; }
.animate-slide-up { animation: slideUp var(--transition-slow) ease; }
.animate-scale-in { animation: scaleIn var(--transition-spring); }
/* ─── Grain texture ──────────────────────── */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.015;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
export const MainLayout: React.FC = () => {
return (
<>
<Header />
<main className="page">
<Outlet />
</main>
<Footer />
</>
);
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+238
View File
@@ -0,0 +1,238 @@
import React, { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/services/api';
import { Loader } from '@/components/Loader';
import { useAuthStore } from '@/features/auth/store';
import { Navigate } from 'react-router-dom';
export const AdminPage: React.FC = () => {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'reservations' | 'stoplist'>('reservations');
const [categoryFilter, setCategoryFilter] = useState<string>('__all__');
if (user?.role !== 'ADMIN') {
return <Navigate to="/" />;
}
const { data: resData, isLoading: resLoading } = useQuery({
queryKey: ['admin-reservations'],
queryFn: () => api.get('/reservations').then(res => res.data)
});
const { data: dishesData, isLoading: dishesLoading } = useQuery({
queryKey: ['admin-dishes'],
queryFn: () => api.get('/dishes').then(res => res.data)
});
const updateStatusMutation = useMutation({
mutationFn: ({ id, status }: { id: number, status: string }) => api.put(`/reservations/${id}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-reservations'] });
}
});
const toggleAvailabilityMutation = useMutation({
mutationFn: ({ id, availability }: { id: number, availability: string }) => api.put(`/dishes/${id}/availability`, { availability }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-dishes'] });
}
});
const reservations = resData?.data || [];
const dishes = dishesData?.data || [];
const categories = useMemo(() => {
const set = new Set<string>();
for (const d of dishes) {
const title = d?.category?.title;
if (typeof title === 'string' && title.trim()) set.add(title);
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'ru'));
}, [dishes]);
const filteredDishes = useMemo(() => {
if (categoryFilter === '__all__') return dishes;
return dishes.filter((d: any) => d?.category?.title === categoryFilter);
}, [dishes, categoryFilter]);
const stoppedDishes = useMemo(() => {
return dishes.filter((d: any) => d?.availability === 'STOPPED');
}, [dishes]);
return (
<div className="container">
<h1 className="mb-6 font-display text-2xl mb-8" style={{ color: 'var(--color-gold-500)' }}>Панель Администратора</h1>
<div className="tabs mb-6">
<div
className={`tab ${activeTab === 'reservations' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('reservations')}
>
Бронирования
</div>
<div
className={`tab ${activeTab === 'stoplist' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('stoplist')}
>
Стоп-лист {stoppedDishes.length > 0 ? `(${stoppedDishes.length})` : ''}
</div>
</div>
{activeTab === 'reservations' && (
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800">
<h3 className="m-0">Управление бронями</h3>
</div>
{resLoading ? <Loader /> : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Дата / Время</th>
<th>Пользователь</th>
<th>Стол</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{reservations.map((r: any) => (
<tr key={r.id}>
<td>#{r.id}</td>
<td>{new Date(r.date + 'T00:00:00').toLocaleDateString('ru')}<br/><span className="text-muted">{r.time}</span></td>
<td>{r.user.name} {r.user.surname}<br/><span className="text-muted">{r.user.phone}</span></td>
<td>{r.pub.address.split(',')[0]}<br/><span className="text-gold-500 font-bold text-xs" style={{ color: 'var(--color-gold-500)' }}>Стол {r.table.number}</span> ({r.numPeople}ч)</td>
<td>
<span className={`badge ${r.status === 'CONFIRMED' ? 'badge-green' : r.status === 'PENDING' ? 'badge-gold' : r.status === 'CANCELLED' ? 'badge-red' : 'badge-gray'}`}>
{r.status}
</span>
</td>
<td>
<select
className="input"
style={{ padding: '4px 8px', minHeight: 'auto', fontSize: '12px' }}
value={r.status}
onChange={(e) => updateStatusMutation.mutate({ id: r.id, status: e.target.value })}
>
<option value="PENDING">Ожидает</option>
<option value="CONFIRMED">Подтверждена</option>
<option value="CANCELLED">Отменена</option>
<option value="COMPLETED">Завершена</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{activeTab === 'stoplist' && (
<div className="grid grid-2 gap-xl" style={{ gridTemplateColumns: 'minmax(0, 5fr) minmax(0, 7fr)' }}>
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800 flex justify-between items-center">
<h3 className="m-0">Блюда в стоп-листе</h3>
<span className="text-secondary text-sm">{stoppedDishes.length} шт.</span>
</div>
<div className="p-4 flex flex-col gap-sm" style={{ maxHeight: '600px', overflowY: 'auto' }}>
{dishesLoading ? <Loader /> : stoppedDishes.length === 0 ? (
<div className="text-secondary">Сейчас нет блюд в стоп-листе.</div>
) : stoppedDishes.map((dish: any) => (
<div
key={dish.id}
className="flex justify-between items-center p-3 rounded"
style={{ background: 'var(--color-error-bg)', border: `1px solid var(--color-error)` }}
>
<div className="font-semibold">
{dish.title}{' '}
<span className="text-muted text-xs font-normal">({dish.category.title})</span>
</div>
<ButtonOrLink
onClick={() => toggleAvailabilityMutation.mutate({ id: dish.id, availability: 'AVAILABLE' })}
/>
</div>
))}
</div>
</section>
<section className="card p-0 overflow-hidden">
<div className="p-4 border-b border-zinc-800 flex justify-between items-center">
<h3 className="m-0">Стоп-лист блюд</h3>
<span className="text-secondary text-sm">Вкл = в стопе</span>
</div>
<div className="p-4 border-b border-zinc-800 flex items-center gap-md" style={{ background: 'var(--color-bg-elevated)' }}>
<label className="text-secondary text-sm" style={{ minWidth: 90 }}>Категория</label>
<select
className="input"
style={{ padding: '6px 10px', minHeight: 'auto', fontSize: '12px' }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="__all__">Все</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="p-4 flex flex-col gap-sm" style={{ maxHeight: '540px', overflowY: 'auto' }}>
{dishesLoading ? <Loader /> : filteredDishes.map((dish: any) => (
<div
key={dish.id}
className="flex justify-between items-center p-3 rounded"
style={{
background: dish.availability === 'STOPPED' ? 'var(--color-error-bg)' : 'var(--color-bg-input)',
border: `1px solid ${dish.availability === 'STOPPED' ? 'var(--color-error)' : 'transparent'}`
}}
>
<div>
<div className="font-semibold">{dish.title} <span className="text-muted text-xs font-normal">({dish.category.title})</span></div>
</div>
<div
className="toggle-wrapper"
onClick={() => toggleAvailabilityMutation.mutate({ id: dish.id, availability: dish.availability === 'AVAILABLE' ? 'STOPPED' : 'AVAILABLE' })}
>
<div
className={`toggle ${dish.availability === 'STOPPED' ? 'active' : ''}`}
style={{
borderColor: dish.availability === 'STOPPED' ? 'var(--color-error)' : undefined,
background: dish.availability === 'STOPPED' ? 'var(--color-error)' : undefined
}}
/>
</div>
</div>
))}
</div>
</section>
</div>
)}
</div>
);
};
const ButtonOrLink: React.FC<{ onClick: () => void }> = ({ onClick }) => {
return (
<button
type="button"
className="btn btn-ghost"
style={{
padding: '6px 10px',
fontSize: '12px',
border: '1px solid var(--color-border-light)',
borderRadius: '999px',
color: 'var(--color-text)',
background: 'transparent',
cursor: 'pointer'
}}
onClick={onClick}
>
Убрать
</button>
);
};
+384
View File
@@ -0,0 +1,384 @@
import React, { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { useBookingStore } from '@/features/booking/store';
import { useAuthStore } from '@/features/auth/store';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { Loader } from '@/components/Loader';
import { FloorPlan } from '@/features/booking/components/FloorPlan';
import { DishSelector } from '@/features/booking/components/DishSelector';
export const BookingPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated, user } = useAuthStore();
const state = useBookingStore();
const [step, setStep] = useState<1 | 2 | 3>(1); // 1: Details & Table, 2: Dishes, 3: Payment/Confirm
const [paymentMethod, setPaymentMethod] = useState<'saved' | 'new'>('new');
const [paymentCardNumber, setPaymentCardNumber] = useState('');
const [paymentCardExpiry, setPaymentCardExpiry] = useState('');
const [paymentCardCvv, setPaymentCardCvv] = useState('');
// Queries
const { data: pubsRes, isLoading: pubsLoading } = useQuery({
queryKey: ['pubs'],
queryFn: () => api.get('/pubs').then(res => res.data)
});
const { data: tablesRes, isLoading: tablesLoading, isFetching: tablesFetching } = useQuery({
queryKey: ['tables-avail', state.pubId, state.date, state.time],
queryFn: () => api.get(`/pubs/${state.pubId}/tables/availability?date=${state.date}&time=${state.time}`).then(res => res.data),
enabled: !!(state.pubId && state.date && state.time)
});
// Mutations
const createReservationMutation = useMutation({
mutationFn: (data: any) => api.post('/reservations', data).then(res => res.data),
onSuccess: () => {
state.reset();
navigate('/profile');
},
onError: (err: any) => {
alert(err.response?.data?.error || 'Ошибка при бронировании');
}
});
const paymentMutation = useMutation({
mutationFn: (data: any) => api.post('/payments/create', data).then(res => res.data)
});
// Today string for min date
const today = new Date().toISOString().split('T')[0];
const pubs = pubsRes?.data || [];
const tables = tablesRes?.data || [];
// Set default pub
React.useEffect(() => {
if (pubs.length > 0 && !state.pubId) {
state.setPubId(pubs[0].id);
}
}, [pubs, state.pubId]);
if (!isAuthenticated) {
return (
<div className="container" style={{ textAlign: 'center', paddingTop: 'var(--space-4xl)' }}>
<h2 style={{ marginBottom: 'var(--space-md)' }}>Требуется авторизация</h2>
<p className="text-secondary" style={{ marginBottom: 'var(--space-xl)' }}>Для бронирования стола необходимо войти в личный кабинет.</p>
<Button variant="primary" onClick={() => navigate('/')}>Вернуться на главную</Button>
</div>
);
}
const handleNextToDishes = () => {
if (!state.pubId || !state.date || !state.time || !state.numPeople || !state.tableId) {
alert('Пожалуйста, заполните все поля и выберите стол');
return;
}
setStep(2);
};
const handleConfirmReservation = async () => {
const payload = {
pubId: state.pubId,
tableId: state.tableId,
date: state.date,
time: state.time,
numPeople: state.numPeople,
dishes: state.includeDishes && state.dishes.length > 0
? state.dishes.map(d => ({ dishId: d.dishId, quantity: d.quantity }))
: undefined
};
if (payload.dishes && payload.dishes.length > 0) {
setPaymentMethod(user?.cardLastFour ? 'saved' : 'new');
setPaymentCardNumber('');
setPaymentCardExpiry('');
setPaymentCardCvv('');
setStep(3);
} else {
createReservationMutation.mutate(payload);
}
};
const handleMockPayment = async () => {
let cardLastFour: string;
let cardExpiry: string;
if (paymentMethod === 'saved') {
if (!user?.cardLastFour || !user?.cardExpiry) {
alert('Привязанная карта не найдена. Введите данные новой карты.');
return;
}
cardLastFour = user.cardLastFour;
cardExpiry = user.cardExpiry;
} else {
const cardDigits = paymentCardNumber.replace(/\D/g, '');
if (cardDigits.length !== 16) {
alert('Введите корректный номер карты (16 цифр)');
return;
}
if (!/^\d{2}\/\d{2}$/.test(paymentCardExpiry)) {
alert('Введите срок действия карты в формате ММ/ГГ');
return;
}
cardLastFour = cardDigits.slice(-4);
cardExpiry = paymentCardExpiry;
}
if (!/^\d{3,4}$/.test(paymentCardCvv)) {
alert('Введите корректный CVC/CVV');
return;
}
const payload = {
pubId: state.pubId,
tableId: state.tableId,
date: state.date,
time: state.time,
numPeople: state.numPeople,
dishes: state.dishes.map(d => ({ dishId: d.dishId, quantity: d.quantity }))
};
createReservationMutation.mutate(payload, {
onSuccess: async (res) => {
await paymentMutation.mutateAsync({
amount: Number(res.data.prepaid),
reservationId: res.data.id,
cardData: {
cardLastFour,
cardExpiry,
},
});
}
});
};
const formatCardNumber = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(.{4})/g, '$1 ').trim();
};
const formatCardExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};
const hasSavedCard = Boolean(user?.cardLastFour);
const totalCost = state.getTotalCost();
const prepaidAmount = Math.round(totalCost * 0.5);
if (pubsLoading) return <Loader />;
return (
<div className="container" style={{ maxWidth: '900px' }}>
<h1 className="text-center" style={{ marginBottom: 'var(--space-xl)', color: 'var(--color-gold-500)' }}>Бронирование стола</h1>
{/* Stepper indicator */}
<div className="flex justify-center items-center gap-md" style={{ marginBottom: 'var(--space-2xl)' }}>
<div className={`badge ${step >= 1 ? 'badge-gold' : 'badge-gray'}`}>1. Стол</div>
<div style={{ width: '40px', height: '2px', background: step >= 2 ? 'var(--color-gold-500)' : 'var(--color-border)' }} />
<div className={`badge ${step >= 2 ? 'badge-gold' : 'badge-gray'}`}>2. Предзаказ</div>
<div style={{ width: '40px', height: '2px', background: step >= 3 ? 'var(--color-gold-500)' : 'var(--color-border)' }} />
<div className={`badge ${step >= 3 ? 'badge-gold' : 'badge-gray'}`}>3. Оплата</div>
</div>
{step === 1 && (
<div className="card animate-fade-in flex-col gap-lg">
<div className="grid grid-2 gap-md">
<div>
<label className="input-label">Паб</label>
<select className="input" value={state.pubId || ''} onChange={(e) => state.setPubId(Number(e.target.value))}>
{pubs.map((p: any) => <option key={p.id} value={p.id}>{p.address}</option>)}
</select>
</div>
<Input label="Количество гостей" type="number" min="1" max="20" value={state.numPeople} onChange={(e) => state.setNumPeople(parseInt(e.target.value))} />
<Input label="Дата" type="date" min={today} value={state.date} onChange={(e) => state.setDate(e.target.value)} />
<Input label="Время" type="time" min="12:00" max="02:00" value={state.time} onChange={(e) => state.setTime(e.target.value)} />
</div>
<div className="divider" />
<div>
<div className="flex justify-between items-center mb-2">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Выберите стол</h3>
{tablesFetching && <span className="text-sm text-secondary animate-pulse">Обновление...</span>}
</div>
{(!state.date || !state.time) ? (
<div className="text-center p-8 bg-zinc-900 rounded border border-zinc-800 text-secondary">
Пожалуйста, выберите дату и время для просмотра свободных столов
</div>
) : tablesLoading ? (
<Loader />
) : (
<>
<FloorPlan tables={tables} selectedTableId={state.tableId} onSelectTable={state.setTableId} />
<div className="flex justify-center gap-lg" style={{ marginTop: 'var(--space-md)' }}>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-green-700)', borderRadius:'50%' }}/> Свободен</div>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-error-bg)', borderRadius:'50%' }}/> Занят / Не подходит</div>
<div className="flex items-center gap-sm text-sm"><span style={{ display:'inline-block', width:12, height:12, background:'var(--color-gold-500)', borderRadius:'50%' }}/> Выбран</div>
</div>
{state.tableId && (() => {
const sTable = tables.find((t: any) => t.id === state.tableId);
return sTable && sTable.capacity < state.numPeople && (
<div className="badge badge-red mt-4 ml-auto mr-auto block text-center" style={{ maxWidth: 300 }}>
Внимание: вместимость стола ({sTable.capacity}) меньше кол-ва гостей ({state.numPeople})
</div>
);
})()}
</>
)}
</div>
<div className="flex justify-end mt-4">
<Button variant="primary" size="lg" disabled={!state.tableId} onClick={handleNextToDishes}>
Далее
</Button>
</div>
</div>
)}
{step === 2 && (
<div className="animate-fade-in flex-col gap-lg">
<div className="card mb-4 flex justify-between items-center">
<div>
<p className="font-semibold">Предзаказ блюд <span className="badge badge-gray ml-2 text-xs">По желанию</span></p>
<p className="text-sm text-secondary">Оформляя предзаказ, вы экономите время ожидания. Требуется 50% предоплата за блюда.</p>
</div>
<div className="toggle-wrapper" onClick={() => state.setIncludeDishes(!state.includeDishes)}>
<div className={`toggle ${state.includeDishes ? 'active' : ''}`} />
</div>
</div>
{state.includeDishes && state.pubId && (
<div className="card">
<DishSelector pubId={state.pubId} />
</div>
)}
<div className="flex justify-between items-center mt-6 p-4 bg-zinc-900 rounded border border-zinc-800">
<div>
<Button variant="ghost" onClick={() => setStep(1)}>Назад</Button>
</div>
<div className="flex items-center gap-lg">
{state.includeDishes && state.dishes.length > 0 && (
<div style={{ textAlign: 'right' }}>
<p className="text-sm text-secondary">Итого (Предоплата):</p>
<p className="font-display font-bold text-lg" style={{ color: 'var(--color-gold-400)' }}>{prepaidAmount} </p>
</div>
)}
<Button variant="primary" size="lg" onClick={handleConfirmReservation} isLoading={createReservationMutation.isPending}>
{state.includeDishes && state.dishes.length > 0 ? 'Перейти к оплате' : 'Подтвердить бронь'}
</Button>
</div>
</div>
</div>
)}
{step === 3 && (
<div className="card animate-fade-in flex-col items-center justify-center p-8 gap-lg" style={{ minHeight: '400px' }}>
<h2 style={{ color: 'var(--color-gold-500)' }}>Оплата предзаказа</h2>
<p className="text-secondary text-center mb-6">Сумма к оплате (50%): <strong>{prepaidAmount} </strong></p>
<div className="p-6 bg-zinc-950 rounded border border-zinc-800 w-full max-w-sm mb-6">
<p className="text-sm text-muted text-center mb-4">MOCK ПЛАТЕЖНЫЙ ШЛЮЗ</p>
{hasSavedCard && (
<div className="flex flex-col gap-sm mb-4">
<label
className="flex items-center gap-sm p-3 rounded cursor-pointer"
style={{
background: paymentMethod === 'saved' ? 'var(--color-bg-elevated)' : 'transparent',
border: `1px solid ${paymentMethod === 'saved' ? 'var(--color-gold-500)' : 'var(--color-border)'}`,
}}
>
<input
type="radio"
name="paymentMethod"
checked={paymentMethod === 'saved'}
onChange={() => setPaymentMethod('saved')}
/>
<span className="text-sm">
💳 {user!.cardLastFour}
{user!.cardExpiry ? ` (до ${user!.cardExpiry})` : ''}
</span>
</label>
<label
className="flex items-center gap-sm p-3 rounded cursor-pointer"
style={{
background: paymentMethod === 'new' ? 'var(--color-bg-elevated)' : 'transparent',
border: `1px solid ${paymentMethod === 'new' ? 'var(--color-gold-500)' : 'var(--color-border)'}`,
}}
>
<input
type="radio"
name="paymentMethod"
checked={paymentMethod === 'new'}
onChange={() => setPaymentMethod('new')}
/>
<span className="text-sm">Оплатить другой картой</span>
</label>
</div>
)}
{paymentMethod === 'new' && (
<>
<Input
label="Номер карты"
placeholder="0000 0000 0000 0000"
value={paymentCardNumber}
onChange={(e) => setPaymentCardNumber(formatCardNumber(e.target.value))}
maxLength={19}
/>
<div className="grid grid-2 gap-md mt-4">
<Input
label="Срок действия"
placeholder="ММ/ГГ"
value={paymentCardExpiry}
onChange={(e) => setPaymentCardExpiry(formatCardExpiry(e.target.value))}
maxLength={5}
/>
<Input
label="CVC"
placeholder="***"
type="password"
value={paymentCardCvv}
onChange={(e) => setPaymentCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
maxLength={4}
/>
</div>
</>
)}
{paymentMethod === 'saved' && (
<Input
label="CVC"
placeholder="***"
type="password"
value={paymentCardCvv}
onChange={(e) => setPaymentCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
maxLength={4}
/>
)}
</div>
<div className="flex justify-center gap-md">
<Button variant="ghost" onClick={() => setStep(2)}>Отмена</Button>
<Button variant="primary" size="lg" onClick={handleMockPayment} isLoading={createReservationMutation.isPending || paymentMutation.isPending}>
Оплатить {prepaidAmount}
</Button>
</div>
</div>
)}
</div>
);
};
+213
View File
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { Button } from '@/components/Button';
import { Loader } from '@/components/Loader';
import { useBookingStore } from '@/features/booking/store';
import type { IPub, IDish } from '@harats/shared';
export const HomePage: React.FC = () => {
const navigate = useNavigate();
const setPubId = useBookingStore(state => state.setPubId);
const [selectedPubId, setSelectedPubId] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<string>('');
// Queries
const { data: pubsRes, isLoading: pubsLoading } = useQuery({
queryKey: ['pubs'],
queryFn: () => api.get('/pubs').then(res => res.data)
});
const { data: menuRes, isLoading: menuLoading } = useQuery({
queryKey: ['menu', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/menu`).then(res => res.data),
enabled: !!selectedPubId
});
const { data: lunchRes } = useQuery({
queryKey: ['lunch', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/lunch-menu`).then(res => res.data),
enabled: !!selectedPubId
});
const { data: billboardRes } = useQuery({
queryKey: ['billboard', selectedPubId],
queryFn: () => api.get(`/pubs/${selectedPubId}/billboard`).then(res => res.data),
enabled: !!selectedPubId
});
const pubs: IPub[] = pubsRes?.data || [];
const menu: Record<string, any[]> = menuRes?.data || {};
const lunch: any[] = lunchRes?.data || [];
const billboard: any[] = billboardRes?.data || [];
// Set default pub on load
React.useEffect(() => {
if (pubs.length > 0 && !selectedPubId) {
setSelectedPubId(pubs[0].id);
}
}, [pubs, selectedPubId]);
// Set default tab on menu load
React.useEffect(() => {
if (Object.keys(menu).length > 0 && !activeTab) {
setActiveTab(Object.keys(menu)[0]);
}
}, [menu, activeTab]);
const handleBooking = () => {
if (selectedPubId) {
setPubId(selectedPubId);
navigate('/booking');
}
};
if (pubsLoading) return <Loader />;
const selectedPub = pubs.find(p => p.id === selectedPubId);
return (
<div>
{/* Hero Section */}
<section style={{
position: 'relative',
padding: 'var(--space-4xl) 0',
background: 'linear-gradient(rgba(13,13,13,0.7), rgba(13,13,13,0.9)), url("https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&q=80") center/cover',
textAlign: 'center',
borderBottom: '1px solid var(--color-border)'
}}>
<div className="container">
<h1 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Добро пожаловать в Harat's</h1>
<p className="text-lg text-secondary" style={{ maxWidth: '600px', margin: '0 auto var(--space-xl)' }}>
Ирландский паб, где каждый вечер превращается в праздник.
Живая музыка, отличная компания и уютный хаос.
</p>
<div style={{
background: 'var(--color-bg-card)',
padding: 'var(--space-lg)',
borderRadius: 'var(--radius-xl)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-md)',
maxWidth: '800px',
margin: '0 auto',
border: '1px solid var(--color-border-light)'
}}>
<div style={{ flex: 1, textAlign: 'left' }}>
<label className="input-label" style={{ display: 'block', marginBottom: 'var(--space-xs)' }}>Выберите паб</label>
<select
className="input"
value={selectedPubId || ''}
onChange={(e) => setSelectedPubId(Number(e.target.value))}
style={{ background: 'var(--color-bg-input)' }}
>
{pubs.map(pub => (
<option key={pub.id} value={pub.id}>{pub.address}</option>
))}
</select>
</div>
{selectedPub && (
<div style={{ padding: '0 var(--space-md)', borderLeft: '1px solid var(--color-border)' }}>
<p className="text-secondary text-sm">Телефон</p>
<p style={{ fontWeight: 600 }}>{selectedPub.phone}</p>
</div>
)}
<Button variant="primary" size="lg" onClick={handleBooking}>Забронировать стол</Button>
</div>
</div>
</section>
<div className="container" style={{ marginTop: 'var(--space-3xl)' }}>
{/* Billboard */}
{billboard.length > 0 && (
<section style={{ marginBottom: 'var(--space-4xl)' }}>
<h2 style={{ marginBottom: 'var(--space-xl)' }}>Афиша</h2>
<div className="grid grid-3 gap-lg">
{billboard.map(event => (
<div key={event.id} className="card card-hover flex-col justify-between" style={{ minHeight: '160px' }}>
<div>
<div className="badge badge-gold mb-2">{new Date(event.datetime).toLocaleString('ru', { month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
<h4 style={{ marginTop: 'var(--space-sm)' }}>{event.title}</h4>
<p className="text-secondary text-sm">{event.artist}</p>
</div>
</div>
))}
</div>
</section>
)}
<div className="grid gap-4XL" style={{ gridTemplateColumns: 'minmax(0, 1fr) 300px', gap: 'var(--space-2xl)' }}>
{/* Main Menu */}
<section>
<h2 style={{ marginBottom: 'var(--space-xl)' }}>Меню</h2>
{menuLoading ? <Loader /> : Object.keys(menu).length > 0 ? (
<>
<div className="tabs" style={{ marginBottom: 'var(--space-xl)' }}>
{Object.keys(menu).map(cat => (
<div
key={cat}
className={`tab ${activeTab === cat ? 'tab-active' : ''}`}
onClick={() => setActiveTab(cat)}
>
{cat}
</div>
))}
</div>
<div className="grid grid-2 gap-md">
{menu[activeTab]?.map(item => (
<div key={item.dish.id} className="card" style={{ padding: 'var(--space-md)' }}>
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-xs)' }}>
<h4 style={{ fontSize: '1rem' }}>{item.dish.title}</h4>
<span style={{ color: 'var(--color-gold-500)', fontWeight: 600, whiteSpace: 'nowrap' }}>
{item.dish.price} ₽
</span>
</div>
<p className="text-sm text-secondary line-clamp-2">{item.dish.description}</p>
{item.dish.grams && <p className="text-xs text-muted" style={{ marginTop: 'var(--space-xs)' }}>{item.dish.grams} г</p>}
</div>
))}
</div>
</>
) : (
<p className="text-secondary">Меню пока не загружено.</p>
)}
</section>
{/* Business Lunch Sidebar */}
<aside>
<div className="card" style={{ borderTop: '4px solid var(--color-green-500)', background: 'var(--color-bg-elevated)', position: 'sticky', top: 'calc(var(--header-height) + var(--space-xl))' }}>
<div className="badge badge-green" style={{ marginBottom: 'var(--space-md)' }}>12:00 — 16:00</div>
<h3 style={{ marginBottom: 'var(--space-xs)' }}>Бизнес-ланч</h3>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-lg)' }}>Доступен только при заказе в пабе. Предзаказ невозможен.</p>
{lunch.length > 0 ? (
<div className="flex flex-col gap-sm">
{lunch.map(item => (
<div key={item.dish.id} style={{ borderBottom: '1px dashed var(--color-border-light)', paddingBottom: 'var(--space-sm)' }}>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{item.dish.title}</span>
<span className="text-sm" style={{ color: 'var(--color-gold-400)' }}>{item.dish.price} </span>
</div>
<p className="text-xs text-muted">{item.dish.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">Ланч-меню на эту неделю еще не опубликовано.</p>
)}
</div>
</aside>
</div>
</div>
</div>
);
};
+487
View File
@@ -0,0 +1,487 @@
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/services/api';
import { useAuthStore } from '@/features/auth/store';
import { Loader } from '@/components/Loader';
import { Button } from '@/components/Button';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/Input';
export const ProfilePage: React.FC = () => {
const { user, logout, updateUser } = useAuthStore();
const navigate = useNavigate();
const [profileName, setProfileName] = useState(user?.name || '');
const [profileSurname, setProfileSurname] = useState(user?.surname || '');
const [profilePhone, setProfilePhone] = useState(user?.phone || '');
const [profileEmail, setProfileEmail] = useState(user?.email || '');
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSuccess, setProfileSuccess] = useState<string | null>(null);
const [showCardForm, setShowCardForm] = useState(false);
const [showDeleteCard, setShowDeleteCard] = useState(false);
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [cardError, setCardError] = useState<string | null>(null);
React.useEffect(() => {
if (user) {
setProfileName(user.name || '');
setProfileSurname(user.surname || '');
setProfilePhone(user.phone || '');
setProfileEmail(user.email || '');
}
}, [user]);
const { data: resData, isLoading } = useQuery({
queryKey: ['my-reservations'],
queryFn: () => api.get('/reservations/my').then(res => res.data),
staleTime: 0,
});
const reservations = resData?.data || [];
const formatCardNumber = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(.{4})/g, '$1 ').trim();
};
const formatCardExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};
const resetCardForm = () => {
setCardNumber('');
setCardExpiry('');
setCardCvv('');
setCurrentPassword('');
setCardError(null);
setShowCardForm(false);
setShowDeleteCard(false);
};
const updateProfileMutation = useMutation({
mutationFn: async () => {
if (!profileName.trim()) {
throw new Error('Имя обязательно для заполнения');
}
if (!profilePhone.trim()) {
throw new Error('Телефон обязателен для заполнения');
}
const { data } = await api.put('/users/me', {
name: profileName.trim(),
surname: profileSurname.trim() || null,
phone: profilePhone.trim(),
email: profileEmail.trim() || null,
});
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({
name: res.data.name,
surname: res.data.surname,
phone: res.data.phone,
email: res.data.email,
});
setProfileError(null);
setProfileSuccess('Данные профиля сохранены');
}
},
onError: (err: any) => {
setProfileSuccess(null);
setProfileError(err.response?.data?.error || err.message || 'Не удалось сохранить профиль');
},
});
const linkCardMutation = useMutation({
mutationFn: async (payload: { cardNumber: string; cardExpiry: string; cardCvv: string; currentPassword?: string }) => {
const digits = payload.cardNumber.replace(/\D/g, '');
if (digits.length !== 16) {
throw new Error('Введите корректный номер карты (16 цифр)');
}
if (!/^\d{2}\/\d{2}$/.test(payload.cardExpiry)) {
throw new Error('Введите срок действия в формате ММ/ГГ');
}
if (!/^\d{3,4}$/.test(payload.cardCvv)) {
throw new Error('Введите корректный CVC/CVV');
}
const { data } = await api.put('/users/me/card', {
cardNumber: digits,
cardExpiry: payload.cardExpiry,
cardCvv: payload.cardCvv,
currentPassword: payload.currentPassword,
});
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({ cardLastFour: res.data.cardLastFour, cardExpiry: res.data.cardExpiry });
resetCardForm();
}
},
onError: (err: any) => {
setCardError(err.response?.data?.error || err.message || 'Не удалось привязать карту');
},
});
const unlinkCardMutation = useMutation({
mutationFn: async (password: string) => {
const { data } = await api.delete('/users/me/card', { data: { currentPassword: password } });
return data;
},
onSuccess: (res) => {
if (res?.success) {
updateUser({ cardLastFour: null, cardExpiry: null });
resetCardForm();
}
},
onError: (err: any) => {
setCardError(err.response?.data?.error || err.message || 'Не удалось удалить карту');
},
});
const handleCancel = async (id: number) => {
if (confirm('Вы уверены, что хотите отменить бронь?')) {
try {
await api.put(`/reservations/${id}/cancel`);
// Refresh page to see changes (in real app, invalidate query)
window.location.reload();
} catch (err) {
alert('Ошибка отмены брони');
}
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'PENDING': return <span className="badge badge-gold">Ожидает</span>;
case 'CONFIRMED': return <span className="badge badge-green">Подтверждена</span>;
case 'CANCELLED': return <span className="badge badge-red">Отменена</span>;
case 'COMPLETED': return <span className="badge badge-gray">Завершена</span>;
default: return <span className="badge">{status}</span>;
}
};
if (!user) return <Loader />;
return (
<div className="container">
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-2xl)' }}>
<h1 style={{ color: 'var(--color-gold-500)' }}>Личный кабинет</h1>
<Button variant="outline" onClick={() => { logout(); navigate('/'); }}>Выйти</Button>
</div>
<div className="grid" style={{ gridTemplateColumns: 'minmax(0, 1fr) 300px', gap: 'var(--space-2xl)' }}>
<div className="flex flex-col gap-lg">
<section>
<div className="flex justify-between items-center" style={{ marginBottom: 'var(--space-md)' }}>
<h2>Мои бронирования</h2>
</div>
{isLoading ? <Loader /> : reservations.length > 0 ? (
<div className="flex flex-col gap-md">
{reservations.map((r: any) => (
<div key={r.id} className="card">
<div className="flex justify-between items-center" style={{ borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-sm)', marginBottom: 'var(--space-sm)' }}>
<div className="flex items-center gap-md">
<h4 style={{ margin: 0 }}>Бронь #{r.id}</h4>
{getStatusBadge(r.status)}
</div>
<span className="text-secondary">{new Date(r.createdAt).toLocaleDateString('ru')}</span>
</div>
<div className="grid grid-2 gap-md" style={{ marginBottom: 'var(--space-md)' }}>
<div>
<p className="text-sm text-secondary">Паб</p>
<p>{r.pub.name} ({r.pub.address})</p>
</div>
<div>
<p className="text-sm text-secondary">Дата и время</p>
<p>{new Date(r.date + 'T00:00:00').toLocaleDateString('ru')} в {r.time}</p>
</div>
<div>
<p className="text-sm text-secondary">Стол</p>
<p>{r.table.number} ({r.numPeople} чел.)</p>
</div>
<div>
<p className="text-sm text-secondary">Гости</p>
<p>{r.numPeople} персон</p>
</div>
</div>
{r.dishes && r.dishes.length > 0 && (
<div style={{ background: 'var(--color-bg-elevated)', padding: 'var(--space-md)', borderRadius: 'var(--radius-md)' }}>
<h5 style={{ marginBottom: 'var(--space-sm)' }}>Предзаказ блюд</h5>
<ul className="text-sm">
{r.dishes.map((d: any) => (
<li key={d.id} className="flex justify-between">
<span>{d.dish?.title} <span className="text-muted">x{d.quantity}</span></span>
<span>{d.priceAtOrder * d.quantity} </span>
</li>
))}
</ul>
<div className="flex justify-between" style={{ marginTop: 'var(--space-md)', paddingTop: 'var(--space-sm)', borderTop: '1px solid var(--color-border)' }}>
<span className="font-semibold">Итого за блюда (Предоплата 50%)</span>
<span className="font-semibold" style={{ color: 'var(--color-gold-400)' }}>{r.prepaid} </span>
</div>
</div>
)}
{['PENDING', 'CONFIRMED'].includes(r.status) && (
<div style={{ marginTop: 'var(--space-md)', textAlign: 'right' }}>
<Button variant="danger" size="sm" onClick={() => handleCancel(r.id)}>Отменить бронь</Button>
</div>
)}
</div>
))}
</div>
) : (
<div className="card text-center" style={{ padding: 'var(--space-3xl) var(--space-md)' }}>
<p className="text-secondary" style={{ marginBottom: 'var(--space-md)' }}>У вас еще нет бронирований</p>
<Button variant="primary" onClick={() => navigate('/booking')}>Забронировать сейчас</Button>
</div>
)}
</section>
</div>
<aside>
<div className="card flex flex-col gap-md">
<h3 style={{ borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-xs)' }}>Профиль</h3>
<Input
label="Имя"
value={profileName}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileName(e.target.value);
}}
/>
<Input
label="Фамилия"
value={profileSurname}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileSurname(e.target.value);
}}
/>
<Input
label="Телефон"
value={profilePhone}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfilePhone(e.target.value);
}}
/>
<Input
label="E-mail"
type="email"
value={profileEmail}
onChange={(e) => {
setProfileError(null);
setProfileSuccess(null);
setProfileEmail(e.target.value);
}}
/>
{profileError && (
<p className="text-sm" style={{ color: 'var(--color-error)' }}>{profileError}</p>
)}
{profileSuccess && (
<p className="text-sm" style={{ color: 'var(--color-green-400)' }}>{profileSuccess}</p>
)}
<Button
variant="primary"
size="sm"
isLoading={updateProfileMutation.isPending}
onClick={() => updateProfileMutation.mutate()}
>
Сохранить профиль
</Button>
<div style={{ marginTop: 'var(--space-md)' }}>
<h4 style={{ marginBottom: 'var(--space-xs)' }}>Привязанная карта</h4>
{user.cardLastFour ? (
<div className="flex items-center gap-sm" style={{ background: 'var(--color-bg-input)', padding: 'var(--space-sm)', borderRadius: 'var(--radius-sm)' }}>
<span>💳</span>
<span> {user.cardLastFour}{user.cardExpiry ? ` (до ${user.cardExpiry})` : ''}</span>
</div>
) : (
<p className="text-sm text-muted">Карта не привязана</p>
)}
{!showCardForm && !showDeleteCard && (
<div className="flex gap-sm" style={{ marginTop: 'var(--space-md)' }}>
{user.cardLastFour ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setCardError(null);
setShowDeleteCard(false);
setShowCardForm(true);
}}
>
Изменить
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
setCardError(null);
setShowCardForm(false);
setCurrentPassword('');
setShowDeleteCard(true);
}}
>
Удалить
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => {
setCardError(null);
setShowCardForm(true);
}}
>
Привязать карту
</Button>
)}
</div>
)}
{showDeleteCard && (
<div style={{ marginTop: 'var(--space-md)' }}>
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>
Для удаления карты введите пароль аккаунта
</p>
<Input
label="Пароль аккаунта"
placeholder="Введите текущий пароль"
type="password"
value={currentPassword}
onChange={(e) => {
setCardError(null);
setCurrentPassword(e.target.value);
}}
/>
{cardError && (
<p className="text-sm" style={{ color: 'var(--color-error)', marginTop: 'var(--space-xs)' }}>{cardError}</p>
)}
<div className="flex gap-sm" style={{ marginTop: 'var(--space-sm)' }}>
<Button
variant="danger"
size="sm"
isLoading={unlinkCardMutation.isPending}
onClick={() => {
if (!currentPassword) {
setCardError('Введите пароль аккаунта');
return;
}
unlinkCardMutation.mutate(currentPassword);
}}
>
Подтвердить удаление
</Button>
<Button variant="ghost" size="sm" onClick={resetCardForm}>
Отмена
</Button>
</div>
</div>
)}
{showCardForm && (
<div style={{ marginTop: 'var(--space-md)' }}>
<Input
label="Номер карты"
placeholder="0000 0000 0000 0000"
value={cardNumber}
onChange={(e) => {
setCardError(null);
setCardNumber(formatCardNumber(e.target.value));
}}
maxLength={19}
/>
<div className="grid grid-2 gap-md" style={{ marginTop: 'var(--space-sm)' }}>
<Input
label="Срок действия"
placeholder="ММ/ГГ"
value={cardExpiry}
onChange={(e) => {
setCardError(null);
setCardExpiry(formatCardExpiry(e.target.value));
}}
maxLength={5}
/>
<Input
label="CVC/CVV"
placeholder="***"
type="password"
value={cardCvv}
onChange={(e) => {
setCardError(null);
setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4));
}}
maxLength={4}
/>
</div>
{user.cardLastFour && (
<Input
label="Пароль аккаунта (для изменения карты)"
placeholder="Введите текущий пароль"
type="password"
value={currentPassword}
onChange={(e) => {
setCardError(null);
setCurrentPassword(e.target.value);
}}
style={{ marginTop: 'var(--space-sm)' } as React.CSSProperties}
/>
)}
{cardError && (
<p className="text-sm" style={{ color: 'var(--color-error)', marginTop: 'var(--space-xs)' }}>{cardError}</p>
)}
<div className="flex gap-sm" style={{ marginTop: 'var(--space-sm)' }}>
<Button
variant="primary"
size="sm"
isLoading={linkCardMutation.isPending}
onClick={() => {
if (user.cardLastFour && !currentPassword) {
setCardError('Для изменения карты введите пароль аккаунта');
return;
}
linkCardMutation.mutate({
cardNumber,
cardExpiry,
cardCvv,
currentPassword: user.cardLastFour ? currentPassword : undefined,
});
}}
>
{user.cardLastFour ? 'Сохранить карту' : 'Привязать карту'}
</Button>
<Button variant="ghost" size="sm" onClick={resetCardForm}>
Отмена
</Button>
</div>
</div>
)}
</div>
</div>
</aside>
</div>
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { createBrowserRouter } from 'react-router-dom';
import { MainLayout } from '@/layouts/MainLayout';
import { HomePage } from '@/pages/HomePage';
import { BookingPage } from '@/pages/BookingPage';
import { ProfilePage } from '@/pages/ProfilePage';
import { AdminPage } from '@/pages/AdminPage';
export const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'booking', element: <BookingPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'admin', element: <AdminPage /> },
],
},
]);
+45
View File
@@ -0,0 +1,45 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
// Attach access token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 — try to refresh token
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
if (data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
original.headers.Authorization = `Bearer ${data.data.accessToken}`;
return api(original);
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/';
}
}
}
return Promise.reject(error);
}
);
export default api;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noEmit": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["vite.config.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════
# Harat's Booking — Server-side deploy script
# ═══════════════════════════════════════════════════════════
#
# Usage:
# bash deploy-remote.sh <archive> <tag>
# bash deploy-remote.sh harats-1.0.0+20260608.tar.gz 1.0.0+20260608
# ═══════════════════════════════════════════════════════════
set -euo pipefail
ARCHIVE="${1:?Missing archive name}"
TAG="${2:?Missing version tag}"
DEPLOY_DIR="/apps/harats"
RELEASES_DIR="$DEPLOY_DIR/releases"
COMPOSE_FILE="docker-compose.prod.yml"
PROJECT_NAME="harats"
echo ""
echo "═══ Harat's Deploy: $TAG ═══"
echo ""
# ── 1. Extract archive ──
echo "[1/5] Extracting $ARCHIVE..."
cd "$DEPLOY_DIR"
tar -xzf "$RELEASES_DIR/$ARCHIVE" --overwrite
chmod +x deploy/deploy-remote.sh 2>/dev/null || true
# ── 2. Ensure directories ──
echo "[2/5] Ensuring directories..."
mkdir -p "$DEPLOY_DIR/envs/prod"
mkdir -p /apps/harats-storage/logs
# Create .env from example if missing
if [ ! -f "$DEPLOY_DIR/envs/prod/.env" ] && [ -f "$DEPLOY_DIR/envs/prod/.env.example" ]; then
cp "$DEPLOY_DIR/envs/prod/.env.example" "$DEPLOY_DIR/envs/prod/.env"
echo " ⚠️ Created envs/prod/.env from example — EDIT SECRETS before use!"
fi
# ── 3. Build Docker image ──
echo "[3/5] Building Docker image..."
DOCKER_TAG="${TAG//+/-}"
export BUILD_VERSION="$DOCKER_TAG"
export BUILD_DATE="$(date -Iseconds)"
echo " → harats-server:$DOCKER_TAG"
docker compose -f "$DEPLOY_DIR/$COMPOSE_FILE" build \
--build-arg BUILD_VERSION="$TAG" \
--build-arg BUILD_DATE="$BUILD_DATE" \
server
docker tag "harats-server:$DOCKER_TAG" harats-server:latest 2>/dev/null || true
# ── 4. Start services ──
echo "[4/5] Starting services..."
BUILD_VERSION="$DOCKER_TAG" docker compose -p "$PROJECT_NAME" -f "$DEPLOY_DIR/$COMPOSE_FILE" up -d --remove-orphans --no-build 2>&1
# ── 5. Health check ──
echo "[5/5] Health check (waiting 10s for startup)..."
sleep 10
HEALTH=$(curl -sf "http://localhost:3001/api/health" 2>/dev/null || echo '{"status":"FAIL"}')
if echo "$HEALTH" | grep -q '"status":"ok"'; then
echo " ✅ Server healthy"
else
echo " ❌ Health check FAILED"
docker compose -p "$PROJECT_NAME" -f "$DEPLOY_DIR/$COMPOSE_FILE" logs --tail=20 server 2>/dev/null || true
fi
# ── Finalize ──
echo "$TAG" > "$DEPLOY_DIR/.current-version"
date -Iseconds > "$DEPLOY_DIR/.last-deploy"
# ── Cleanup old releases ──
cd "$RELEASES_DIR"
ls -t harats-*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -v
docker image prune -f --filter "until=168h" 2>/dev/null || true
echo ""
echo "✅ Deployed: Harat's v$TAG"
echo "═══ Deploy complete ═══"
+261
View File
@@ -0,0 +1,261 @@
<#
.SYNOPSIS
Harat's Booking Deploy — builds and sends release to production server.
.DESCRIPTION
1. Bumps version.json with current build timestamp
2. Builds client (unless -SkipBuild)
3. Creates a tar.gz archive (no node_modules, no .env)
4. Uploads to server via SCP
5. Runs deploy-remote.sh on the server via SSH
.PARAMETER Server
Production server IP/hostname.
.PARAMETER User
SSH username on the server.
.PARAMETER Port
SSH port (default 2182).
.PARAMETER SkipBuild
Skip client build (use existing dist/).
.EXAMPLE
.\deploy\deploy.ps1 # full deploy
.\deploy\deploy.ps1 -SkipBuild # skip client build
#>
param(
[string]$Server = "178.169.87.152",
[string]$User = "eugenius",
[int]$Port = 2182,
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot # harats/
$DeployDir = "/apps/harats"
# -- Error reporting -------------------------------------------------------
function Write-DeployError {
param(
[string]$Step,
[string]$Message,
[string]$Detail = "",
[string]$Hint = ""
)
Write-Host ""
Write-Host " +============================================+" -ForegroundColor Red
Write-Host " | DEPLOY FAILED |" -ForegroundColor Red
Write-Host " +============================================+" -ForegroundColor Red
Write-Host ""
Write-Host " Step: " -NoNewline -ForegroundColor DarkGray
Write-Host "$Step" -ForegroundColor White
Write-Host " Error: " -NoNewline -ForegroundColor DarkGray
Write-Host "$Message" -ForegroundColor Yellow
if ($Detail) {
Write-Host " Detail: " -NoNewline -ForegroundColor DarkGray
Write-Host "$Detail" -ForegroundColor Gray
}
if ($Hint) {
Write-Host ""
Write-Host " Hint: " -NoNewline -ForegroundColor DarkGray
Write-Host "$Hint" -ForegroundColor Cyan
}
Write-Host ""
}
# -- Main deploy pipeline --------------------------------------------------
$currentStep = "Init"
try {
# -- 1. Read version --
$currentStep = "[1/5] Version read"
$versionFile = Join-Path $ProjectRoot "version.json"
if (-not (Test-Path $versionFile)) {
throw "version.json not found at: $versionFile"
}
$versionData = Get-Content $versionFile -Raw | ConvertFrom-Json
$version = $versionData.version
if (-not $version) { throw "version.json is missing the 'version' field" }
$build = Get-Date -Format "yyyyMMdd-HHmm"
$tag = "${version}+${build}"
Write-Host ""
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host " Harat's Deploy - v${tag}" -ForegroundColor Cyan
Write-Host " Target: ${User}@${Server}:${Port}" -ForegroundColor Cyan
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host ""
# -- 2. Update version.json --
$currentStep = "[1/5] Version update"
$versionData.build = $build
$versionData.buildDate = (Get-Date -Format "o")
$jsonOut = $versionData | ConvertTo-Json -Depth 3
[System.IO.File]::WriteAllText($versionFile, $jsonOut, [System.Text.UTF8Encoding]::new($false))
Write-Host "[1/5] Version: $tag" -ForegroundColor Green
# -- 3. Build client --
$currentStep = "[2/5] Client build"
if (-not $SkipBuild) {
Write-Host "[2/5] Building client..." -ForegroundColor Yellow
# Build shared first
$sharedDir = Join-Path $ProjectRoot "packages\shared"
if (Test-Path (Join-Path $sharedDir "package.json")) {
Push-Location $sharedDir
try {
npm run build 2>$null
} finally {
Pop-Location
}
}
$clientDir = Join-Path $ProjectRoot "client"
if (-not (Test-Path (Join-Path $clientDir "package.json"))) {
throw "client/package.json not found -- is the workspace intact?"
}
Push-Location $clientDir
try {
npm run build
if ($LASTEXITCODE -ne 0) {
throw "Vite build exited with code $LASTEXITCODE -- check the compiler output above"
}
} finally {
Pop-Location
}
# Build server
Write-Host " Building server..." -ForegroundColor Yellow
$serverDir = Join-Path $ProjectRoot "server"
Push-Location $serverDir
try {
npm run build
if ($LASTEXITCODE -ne 0) {
throw "Server tsc build exited with code $LASTEXITCODE"
}
} finally {
Pop-Location
}
Write-Host " Build OK" -ForegroundColor Green
} else {
Write-Host "[2/5] Skipping build" -ForegroundColor DarkGray
}
# -- 4. Create archive --
$currentStep = "[3/5] Archive creation"
Write-Host "[3/5] Creating archive..." -ForegroundColor Yellow
$archiveName = "harats-${tag}.tar.gz"
$archivePath = Join-Path $ProjectRoot $archiveName
# Verify git is available (needed for GNU tar)
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if (-not $gitCmd) {
throw "git not found in PATH -- GNU tar from Git for Windows is required to create archives"
}
$gitRoot = $gitCmd.Source | Split-Path | Split-Path
$gnuTar = Join-Path $gitRoot "usr\bin\tar.exe"
if (-not (Test-Path $gnuTar)) {
throw "GNU tar not found at: $gnuTar -- expected Git for Windows installation"
}
Push-Location $ProjectRoot
try {
$oldPath = $env:PATH
$env:PATH = "$gitRoot\usr\bin;$env:PATH"
& $gnuTar -czf $archiveName `
--exclude="node_modules" `
--exclude="*.tar.gz" `
--exclude=".git" `
--exclude="envs/prod/.env" `
server/dist/ `
server/prisma/ `
server/package.json `
server/package-lock.json `
client/dist/ `
packages/shared/ `
Dockerfile `
.dockerignore `
docker-compose.prod.yml `
ecosystem.config.cjs `
version.json `
envs/ `
deploy/
if ($LASTEXITCODE -ne 0) {
throw "tar exited with code $LASTEXITCODE -- check file list for missing paths"
}
$env:PATH = $oldPath
} finally {
Pop-Location
}
if (-not (Test-Path $archivePath)) {
throw "Archive was not created at: $archivePath"
}
$sizeMB = [math]::Round((Get-Item $archivePath).Length / 1MB, 1)
if ($sizeMB -lt 0.1) {
Write-Host " Warning: archive is suspiciously small (${sizeMB} MB)" -ForegroundColor DarkYellow
}
Write-Host " Archive: $archiveName - ${sizeMB} MB" -ForegroundColor Green
# -- 5. Upload --
$currentStep = "[4/5] SCP upload"
Write-Host "[4/5] Uploading to ${Server}..." -ForegroundColor Yellow
scp -P $Port $archivePath "${User}@${Server}:${DeployDir}/releases/"
if ($LASTEXITCODE -ne 0) {
$hint = switch ($LASTEXITCODE) {
255 { "Connection refused or auth failed. Check SSH key, port ($Port), and server availability." }
1 { "SCP error -- target directory ${DeployDir}/releases/ may not exist on the server." }
default { "SCP exited with code $LASTEXITCODE." }
}
throw $hint
}
Write-Host " Upload OK" -ForegroundColor Green
# -- 6. Remote deploy --
$currentStep = "[5/5] Remote deploy (SSH)"
Write-Host "[5/5] Deploying on server..." -ForegroundColor Yellow
ssh -p $Port "${User}@${Server}" "cd ${DeployDir}; cp deploy/deploy-remote.sh /tmp/_harats-deploy.sh; bash /tmp/_harats-deploy.sh '${archiveName}' '${tag}'"
if ($LASTEXITCODE -ne 0) {
$hint = switch ($LASTEXITCODE) {
255 { "SSH connection failed. Is the server reachable at ${Server}:${Port}?" }
126 { "deploy-remote.sh exists but is not executable." }
127 { "deploy-remote.sh not found on server at ${DeployDir}/deploy/deploy-remote.sh" }
default { "Remote script exited with code $LASTEXITCODE -- check server logs" }
}
throw $hint
}
# -- Cleanup local archive --
$currentStep = "Cleanup"
Remove-Item $archivePath -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "=======================================" -ForegroundColor Green
Write-Host " Deployed Harat's v${tag}" -ForegroundColor Green
Write-Host "=======================================" -ForegroundColor Green
Write-Host ""
} catch {
Write-DeployError `
-Step $currentStep `
-Message $_.Exception.Message `
-Detail ($_.ScriptStackTrace -split "`n" | Select-Object -First 3 | Out-String).Trim() `
-Hint "Fix the issue above and re-run: .\deploy\deploy.ps1$(if ($SkipBuild) { ' -SkipBuild' })"
# Cleanup partial archive if it exists
if ($archivePath -and (Test-Path $archivePath -ErrorAction SilentlyContinue)) {
Remove-Item $archivePath -Force -ErrorAction SilentlyContinue
}
exit 1
}
+44
View File
@@ -0,0 +1,44 @@
# ═══════════════════════════════════════════════════════════
# Harat's Booking — Production Docker Compose
# ═══════════════════════════════════════════════════════════
# Deployed via deploy.ps1 → deploy-remote.sh
# Coolify's Caddy handles SSL + routing automatically.
#
# Usage (standalone):
# docker compose -f docker-compose.prod.yml up -d
# ═══════════════════════════════════════════════════════════
services:
server:
image: harats-server:${BUILD_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-1.0.0}
BUILD_DATE: ${BUILD_DATE:-unknown}
restart: unless-stopped
env_file:
- ./envs/prod/.env
environment:
NODE_ENV: production
PORT: 3001
labels:
caddy: harats_demo.swop.su
caddy.reverse_proxy: "{{upstreams 3001}}"
ports:
- "127.0.0.1:3001:3001"
networks:
- default
- coolify
volumes:
- /apps/harats-storage/logs:/data/logs
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: {}
networks:
coolify:
external: true
+49
View File
@@ -0,0 +1,49 @@
/**
* PM2 Ecosystem Config — Harat's Booking
*
* Docker: pm2-runtime ecosystem.config.cjs
* Local: pm2 start ecosystem.config.cjs --env development
*/
module.exports = {
apps: [
{
name: 'harats-server',
cwd: __dirname,
script: 'dist/index.js',
instances: 1,
exec_mode: 'fork',
// Auto-restart
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
restart_delay: 2000,
max_memory_restart: '512M',
// Logs
error_file: '/data/logs/pm2-error.log',
out_file: '/data/logs/pm2-out.log',
merge_logs: true,
time: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// Graceful shutdown
kill_timeout: 5000,
listen_timeout: 8000,
shutdown_with_message: true,
watch: false,
env: {
NODE_ENV: 'production',
PORT: 3001,
},
env_development: {
NODE_ENV: 'development',
PORT: 3001,
},
},
],
};
+21
View File
@@ -0,0 +1,21 @@
# Database (host.docker.internal → host PostgreSQL)
DATABASE_URL=postgresql://postgres:PASSWORD@host.docker.internal:5432/harats_booking
# JWT
JWT_SECRET=CHANGE-ME-IN-PRODUCTION
JWT_REFRESH_SECRET=CHANGE-ME-IN-PRODUCTION
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Server
PORT=3001
NODE_ENV=production
# Client
VITE_API_URL=/api
# Email (Nodemailer)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
+4615
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "harats-booking",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*",
"client",
"server"
],
"scripts": {
"predev": "npx -y kill-port 5173 3001",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "npm run dev --workspace=client",
"dev:server": "npm run dev --workspace=server",
"build": "npm run build --workspace=packages/shared && npm run build --workspace=client && npm run build --workspace=server",
"db:migrate": "npm run db:migrate --workspace=server",
"db:seed": "npm run db:seed --workspace=server",
"db:reset": "npm run db:reset --workspace=server",
"db:init": "npm run db:migrate && npm run db:seed"
},
"devDependencies": {
"concurrently": "^9.1.0",
"typescript": "^5.7.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@harats/shared",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc"
}
}
+180
View File
@@ -0,0 +1,180 @@
// ─── Enums ───────────────────────────────────────────
export enum Role {
USER = 'USER',
ADMIN = 'ADMIN',
}
export enum ReservationStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
}
export enum DishAvailability {
AVAILABLE = 'AVAILABLE',
STOPPED = 'STOPPED',
}
// ─── User ────────────────────────────────────────────
export interface IUser {
id: number;
phone: string;
name: string;
surname?: string | null;
email?: string | null;
role: Role;
cardLastFour?: string | null;
cardExpiry?: string | null;
createdAt: string;
}
export interface IRegisterPayload {
phone: string;
password: string;
name: string;
surname?: string;
email?: string;
}
export interface ILoginPayload {
phone: string;
password: string;
}
export interface IAuthResponse {
user: IUser;
accessToken: string;
refreshToken: string;
}
// ─── Pub ─────────────────────────────────────────────
export interface IPub {
id: number;
address: string;
phone: string;
name: string;
}
// ─── Table ───────────────────────────────────────────
export interface ITable {
id: number;
pubId: number;
number: number;
capacity: number;
posX: number;
posY: number;
width: number;
height: number;
shape: 'rect' | 'circle';
zone?: string | null;
}
export interface ITableAvailability extends ITable {
isAvailable: boolean;
}
// ─── Category ────────────────────────────────────────
export interface ICategory {
id: number;
title: string;
}
// ─── Dish ────────────────────────────────────────────
export interface IDish {
id: number;
title: string;
description?: string | null;
categoryId: number;
category?: ICategory;
grams?: number | null;
price: number;
photo?: string | null;
availability: DishAvailability;
}
// ─── Menu ────────────────────────────────────────────
export interface IMenuItem {
id: number;
pubId: number;
dishId: number;
dish: IDish;
}
export interface ILunchMenuItem {
id: number;
pubId: number;
dishId: number;
dish: IDish;
weekStart: string;
weekEnd: string;
}
// ─── Billboard ───────────────────────────────────────
export interface IBillboard {
id: number;
title: string;
artist: string;
pubId: number;
datetime: string;
poster?: string | null;
}
// ─── Reservation ─────────────────────────────────────
export interface IReservationDish {
id: number;
dishId: number;
dish?: IDish;
quantity: number;
priceAtOrder: number;
}
export interface IReservation {
id: number;
userId: number;
pubId: number;
pub?: IPub;
tableId: number;
table?: ITable;
date: string;
time: string;
numPeople: number;
status: ReservationStatus;
totalCost?: number | null;
prepaid?: number | null;
dishes: IReservationDish[];
createdAt: string;
}
export interface ICreateReservation {
pubId: number;
tableId: number;
date: string;
time: string;
numPeople: number;
dishes?: { dishId: number; quantity: number }[];
}
// ─── API Envelope ────────────────────────────────────
export interface IApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface IPaginatedResponse<T> extends IApiResponse<T[]> {
total: number;
page: number;
limit: number;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@harats/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio"
},
"dependencies": {
"@harats/shared": "*",
"@prisma/client": "^6.4.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.16",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/nodemailer": "^6.4.17",
"prisma": "^6.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
@@ -0,0 +1,193 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateEnum
CREATE TYPE "ReservationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "DishAvailability" AS ENUM ('AVAILABLE', 'STOPPED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"password" VARCHAR(255) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"surname" VARCHAR(100),
"email" VARCHAR(255),
"role" "Role" NOT NULL DEFAULT 'USER',
"cardToken" VARCHAR(255),
"cardLastFour" VARCHAR(4),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Pub" (
"id" SERIAL NOT NULL,
"address" VARCHAR(255) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Pub_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Table" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"number" INTEGER NOT NULL,
"capacity" INTEGER NOT NULL,
"posX" DOUBLE PRECISION NOT NULL,
"posY" DOUBLE PRECISION NOT NULL,
"width" DOUBLE PRECISION NOT NULL DEFAULT 60,
"height" DOUBLE PRECISION NOT NULL DEFAULT 60,
"shape" VARCHAR(20) NOT NULL DEFAULT 'rect',
"zone" VARCHAR(50),
CONSTRAINT "Table_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"title" VARCHAR(100) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Dish" (
"id" SERIAL NOT NULL,
"title" VARCHAR(100) NOT NULL,
"description" TEXT,
"categoryId" INTEGER NOT NULL,
"grams" INTEGER,
"price" DECIMAL(10,2) NOT NULL,
"photo" VARCHAR(255),
"availability" "DishAvailability" NOT NULL DEFAULT 'AVAILABLE',
CONSTRAINT "Dish_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Menu" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
CONSTRAINT "Menu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LunchMenu" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
"weekStart" DATE NOT NULL,
"weekEnd" DATE NOT NULL,
CONSTRAINT "LunchMenu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Billboard" (
"id" SERIAL NOT NULL,
"title" VARCHAR(200) NOT NULL,
"artist" VARCHAR(100) NOT NULL,
"pubId" INTEGER NOT NULL,
"datetime" TIMESTAMP(3) NOT NULL,
"poster" VARCHAR(255),
CONSTRAINT "Billboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reservation" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"pubId" INTEGER NOT NULL,
"tableId" INTEGER NOT NULL,
"date" DATE NOT NULL,
"time" TIME NOT NULL,
"numPeople" INTEGER NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'PENDING',
"totalCost" DECIMAL(10,2),
"prepaid" DECIMAL(10,2),
"paymentId" VARCHAR(255),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reservation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReservationDish" (
"id" SERIAL NOT NULL,
"reservationId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"priceAtOrder" DECIMAL(10,2) NOT NULL,
CONSTRAINT "ReservationDish_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Pub_address_key" ON "Pub"("address");
-- CreateIndex
CREATE UNIQUE INDEX "Table_pubId_number_key" ON "Table"("pubId", "number");
-- CreateIndex
CREATE UNIQUE INDEX "Category_title_key" ON "Category"("title");
-- CreateIndex
CREATE UNIQUE INDEX "Menu_pubId_dishId_key" ON "Menu"("pubId", "dishId");
-- CreateIndex
CREATE UNIQUE INDEX "LunchMenu_pubId_dishId_weekStart_key" ON "LunchMenu"("pubId", "dishId", "weekStart");
-- CreateIndex
CREATE UNIQUE INDEX "ReservationDish_reservationId_dishId_key" ON "ReservationDish"("reservationId", "dishId");
-- AddForeignKey
ALTER TABLE "Table" ADD CONSTRAINT "Table_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Dish" ADD CONSTRAINT "Dish_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Menu" ADD CONSTRAINT "Menu_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Menu" ADD CONSTRAINT "Menu_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LunchMenu" ADD CONSTRAINT "LunchMenu_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LunchMenu" ADD CONSTRAINT "LunchMenu_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Billboard" ADD CONSTRAINT "Billboard_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_tableId_fkey" FOREIGN KEY ("tableId") REFERENCES "Table"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationDish" ADD CONSTRAINT "ReservationDish_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "Reservation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationDish" ADD CONSTRAINT "ReservationDish_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+155
View File
@@ -0,0 +1,155 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
enum ReservationStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum DishAvailability {
AVAILABLE
STOPPED
}
model User {
id Int @id @default(autoincrement())
phone String @unique @db.VarChar(20)
password String @db.VarChar(255)
name String @db.VarChar(100)
surname String? @db.VarChar(100)
email String? @db.VarChar(255)
role Role @default(USER)
cardToken String? @db.VarChar(255)
cardLastFour String? @db.VarChar(4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reservations Reservation[]
}
model Pub {
id Int @id @default(autoincrement())
address String @unique @db.VarChar(255)
phone String @db.VarChar(20)
name String @db.VarChar(100)
createdAt DateTime @default(now())
tables Table[]
menus Menu[]
lunchMenus LunchMenu[]
billboards Billboard[]
reservations Reservation[]
}
model Table {
id Int @id @default(autoincrement())
pubId Int
number Int
capacity Int
posX Float
posY Float
width Float @default(60)
height Float @default(60)
shape String @default("rect") @db.VarChar(20)
zone String? @db.VarChar(50)
pub Pub @relation(fields: [pubId], references: [id])
reservations Reservation[]
@@unique([pubId, number])
}
model Category {
id Int @id @default(autoincrement())
title String @unique @db.VarChar(100)
dishes Dish[]
}
model Dish {
id Int @id @default(autoincrement())
title String @db.VarChar(100)
description String? @db.Text
categoryId Int
grams Int?
price Decimal @db.Decimal(10, 2)
photo String? @db.VarChar(255)
availability DishAvailability @default(AVAILABLE)
category Category @relation(fields: [categoryId], references: [id])
menus Menu[]
lunchMenus LunchMenu[]
orderItems ReservationDish[]
}
model Menu {
id Int @id @default(autoincrement())
pubId Int
dishId Int
pub Pub @relation(fields: [pubId], references: [id])
dish Dish @relation(fields: [dishId], references: [id])
@@unique([pubId, dishId])
}
model LunchMenu {
id Int @id @default(autoincrement())
pubId Int
dishId Int
weekStart DateTime @db.Date
weekEnd DateTime @db.Date
pub Pub @relation(fields: [pubId], references: [id])
dish Dish @relation(fields: [dishId], references: [id])
@@unique([pubId, dishId, weekStart])
}
model Billboard {
id Int @id @default(autoincrement())
title String @db.VarChar(200)
artist String @db.VarChar(100)
pubId Int
datetime DateTime
poster String? @db.VarChar(255)
pub Pub @relation(fields: [pubId], references: [id])
}
model Reservation {
id Int @id @default(autoincrement())
userId Int
pubId Int
tableId Int
date DateTime @db.Date
time DateTime @db.Time()
numPeople Int
status ReservationStatus @default(PENDING)
totalCost Decimal? @db.Decimal(10, 2)
prepaid Decimal? @db.Decimal(10, 2)
paymentId String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
pub Pub @relation(fields: [pubId], references: [id])
table Table @relation(fields: [tableId], references: [id])
dishes ReservationDish[]
}
model ReservationDish {
id Int @id @default(autoincrement())
reservationId Int
dishId Int
quantity Int @default(1)
priceAtOrder Decimal @db.Decimal(10, 2)
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
dish Dish @relation(fields: [dishId], references: [id])
@@unique([reservationId, dishId])
}
+282
View File
@@ -0,0 +1,282 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// ─── Categories ──────────────────────────────────────
const categories = await Promise.all([
prisma.category.upsert({ where: { title: 'Закуски' }, update: {}, create: { title: 'Закуски' } }),
prisma.category.upsert({ where: { title: 'Салаты' }, update: {}, create: { title: 'Салаты' } }),
prisma.category.upsert({ where: { title: 'Супы' }, update: {}, create: { title: 'Супы' } }),
prisma.category.upsert({ where: { title: 'Горячее' }, update: {}, create: { title: 'Горячее' } }),
prisma.category.upsert({ where: { title: 'Бургеры' }, update: {}, create: { title: 'Бургеры' } }),
prisma.category.upsert({ where: { title: 'Пицца' }, update: {}, create: { title: 'Пицца' } }),
prisma.category.upsert({ where: { title: 'Десерты' }, update: {}, create: { title: 'Десерты' } }),
prisma.category.upsert({ where: { title: 'Пиво' }, update: {}, create: { title: 'Пиво' } }),
prisma.category.upsert({ where: { title: 'Коктейли' }, update: {}, create: { title: 'Коктейли' } }),
prisma.category.upsert({ where: { title: 'Безалкогольные' }, update: {}, create: { title: 'Безалкогольные' } }),
prisma.category.upsert({ where: { title: 'Бизнес-ланч' }, update: {}, create: { title: 'Бизнес-ланч' } }),
]);
const [snacks, salads, soups, main, burgers, pizza, desserts, beer, cocktails, soft, lunch] = categories;
// ─── Pubs ────────────────────────────────────────────
const pub1 = await prisma.pub.upsert({
where: { address: 'ул. Карла Маркса, 26, Иркутск' },
update: {},
create: {
name: "Harat's Irish Pub Иркутск",
address: 'ул. Карла Маркса, 26, Иркутск',
phone: '+7 (3952) 48-68-76',
},
});
const pub2 = await prisma.pub.upsert({
where: { address: 'ул. Ленина, 15, Новосибирск' },
update: {},
create: {
name: "Harat's Irish Pub Новосибирск",
address: 'ул. Ленина, 15, Новосибирск',
phone: '+7 (383) 230-01-20',
},
});
const pub3 = await prisma.pub.upsert({
where: { address: 'Невский проспект, 88, Санкт-Петербург' },
update: {},
create: {
name: "Harat's Irish Pub Санкт-Петербург",
address: 'Невский проспект, 88, Санкт-Петербург',
phone: '+7 (812) 640-16-16',
},
});
const pub4 = await prisma.pub.upsert({
where: { address: 'пр. Мира, 81, Красноярск' },
update: {},
create: {
name: "Harat's Irish Pub Красноярск",
address: 'пр. Мира, 81, Красноярск',
phone: '+7 (391) 223-22-22',
},
});
// ─── Tables for Pub 1 (main pub with full layout) ───
const tablesData = [
// Main hall
{ pubId: pub1.id, number: 1, capacity: 2, posX: 80, posY: 100, width: 55, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 2, capacity: 2, posX: 180, posY: 100, width: 55, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 3, capacity: 4, posX: 280, posY: 100, width: 70, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 4, capacity: 4, posX: 400, posY: 100, width: 70, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 5, capacity: 6, posX: 80, posY: 220, width: 90, height: 60, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 6, capacity: 6, posX: 230, posY: 220, width: 90, height: 60, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 7, capacity: 4, posX: 380, posY: 220, width: 70, height: 55, shape: 'rect', zone: 'main' },
// Bar zone
{ pubId: pub1.id, number: 8, capacity: 2, posX: 560, posY: 80, width: 50, height: 50, shape: 'circle', zone: 'bar' },
{ pubId: pub1.id, number: 9, capacity: 2, posX: 560, posY: 160, width: 50, height: 50, shape: 'circle', zone: 'bar' },
{ pubId: pub1.id, number: 10, capacity: 2, posX: 560, posY: 240, width: 50, height: 50, shape: 'circle', zone: 'bar' },
// VIP
{ pubId: pub1.id, number: 11, capacity: 8, posX: 80, posY: 360, width: 120, height: 70, shape: 'rect', zone: 'vip' },
{ pubId: pub1.id, number: 12, capacity: 8, posX: 260, posY: 360, width: 120, height: 70, shape: 'rect', zone: 'vip' },
// Terrace
{ pubId: pub1.id, number: 13, capacity: 4, posX: 460, posY: 360, width: 65, height: 55, shape: 'rect', zone: 'terrace' },
{ pubId: pub1.id, number: 14, capacity: 4, posX: 560, posY: 360, width: 65, height: 55, shape: 'rect', zone: 'terrace' },
];
for (const t of tablesData) {
await prisma.table.upsert({
where: { pubId_number: { pubId: t.pubId, number: t.number } },
update: {},
create: t,
});
}
// Simple tables for pub2, pub3, and pub4
for (const pub of [pub2, pub3, pub4]) {
for (let i = 1; i <= 8; i++) {
await prisma.table.upsert({
where: { pubId_number: { pubId: pub.id, number: i } },
update: {},
create: {
pubId: pub.id,
number: i,
capacity: i <= 4 ? 2 : 4,
posX: 80 + ((i - 1) % 4) * 140,
posY: 100 + Math.floor((i - 1) / 4) * 140,
width: i <= 4 ? 55 : 70,
height: 55,
shape: i <= 4 ? 'circle' : 'rect',
zone: 'main',
},
});
}
}
// ─── Dishes ──────────────────────────────────────────
const dishesData = [
// Snacks
{ title: 'Ирландские колбаски', categoryId: snacks.id, grams: 250, price: 490, description: 'Ассорти из трёх видов домашних колбасок с горчичным соусом' },
{ title: 'Начос с соусами', categoryId: snacks.id, grams: 300, price: 420, description: 'Кукурузные чипсы с гуакамоле, сальсой и сырным соусом' },
{ title: 'Луковые кольца', categoryId: snacks.id, grams: 200, price: 350, description: 'Хрустящие луковые кольца в пивном кляре' },
{ title: 'Куриные крылья BBQ', categoryId: snacks.id, grams: 350, price: 520, description: 'Крылышки в фирменном соусе BBQ с сельдереем' },
{ title: 'Картофель фри', categoryId: snacks.id, grams: 250, price: 280, description: 'Золотистый картофель фри с паприкой' },
{ title: 'Сырные палочки', categoryId: snacks.id, grams: 200, price: 390, description: 'Моцарелла в панировке с томатным соусом' },
// Salads
{ title: 'Цезарь с курицей', categoryId: salads.id, grams: 280, price: 450, description: 'Классический салат с куриной грудкой, пармезаном и гренками' },
{ title: 'Греческий', categoryId: salads.id, grams: 260, price: 380, description: 'Свежие овощи с фетой и оливковым маслом' },
{ title: 'Коул-слоу', categoryId: salads.id, grams: 200, price: 250, description: 'Капустный салат с морковью в сливочной заправке' },
// Soups
{ title: 'Ирландское рагу', categoryId: soups.id, grams: 350, price: 420, description: 'Традиционное рагу из баранины с картофелем и овощами' },
{ title: 'Крем-суп из шампиньонов', categoryId: soups.id, grams: 300, price: 350, description: 'Нежный крем-суп с гренками и трюфельным маслом' },
{ title: 'Томатный суп', categoryId: soups.id, grams: 300, price: 320, description: 'Густой томатный суп с базиликом' },
// Main
{ title: 'Стейк Рибай', categoryId: main.id, grams: 300, price: 1490, description: 'Стейк рибай medium rare с овощами гриль' },
{ title: 'Fish & Chips', categoryId: main.id, grams: 350, price: 590, description: 'Треска в пивном кляре с картофелем фри и соусом тартар' },
{ title: 'Shepherd\'s Pie', categoryId: main.id, grams: 400, price: 520, description: 'Пастуший пирог с бараниной и картофельным пюре' },
{ title: 'Свиная рулька', categoryId: main.id, grams: 500, price: 890, description: 'Томлёная свиная рулька с квашеной капустой' },
// Burgers
{ title: 'Classic Burger', categoryId: burgers.id, grams: 350, price: 550, description: 'Говяжья котлета, чеддер, томат, салат, фирменный соус' },
{ title: 'Irish Burger', categoryId: burgers.id, grams: 400, price: 650, description: 'Двойная котлета, бекон, яйцо, жареный лук, BBQ соус' },
{ title: 'Chicken Burger', categoryId: burgers.id, grams: 330, price: 490, description: 'Куриная котлета, авокадо, руккола, медово-горчичный соус' },
// Pizza
{ title: 'Маргарита', categoryId: pizza.id, grams: 450, price: 490, description: 'Томатный соус, моцарелла, базилик' },
{ title: 'Пепперони', categoryId: pizza.id, grams: 480, price: 590, description: 'Томатный соус, моцарелла, пепперони' },
{ title: 'Четыре сыра', categoryId: pizza.id, grams: 460, price: 620, description: 'Моцарелла, горгонзола, пармезан, дор блю' },
// Desserts
{ title: 'Чизкейк', categoryId: desserts.id, grams: 180, price: 350, description: 'Классический нью-йоркский чизкейк' },
{ title: 'Брауни', categoryId: desserts.id, grams: 150, price: 320, description: 'Шоколадный брауни с ванильным мороженым' },
{ title: 'Apple Crumble', categoryId: desserts.id, grams: 200, price: 380, description: 'Тёплый яблочный крамбл с корицей и мороженым' },
// Beer
{ title: 'Guinness (0.5)', categoryId: beer.id, grams: 500, price: 450, description: 'Ирландский тёмный стаут' },
{ title: 'Kilkenny (0.5)', categoryId: beer.id, grams: 500, price: 420, description: 'Ирландский красный эль' },
{ title: 'Harp Lager (0.5)', categoryId: beer.id, grams: 500, price: 380, description: 'Ирландский лагер' },
{ title: 'Крафт IPA (0.4)', categoryId: beer.id, grams: 400, price: 390, description: 'Крафтовый индийский пэйл эль' },
// Cocktails
{ title: 'Irish Coffee', categoryId: cocktails.id, grams: 250, price: 450, description: 'Кофе, ирландский виски, сливки' },
{ title: 'Whiskey Sour', categoryId: cocktails.id, grams: 200, price: 520, description: 'Виски, лимонный сок, сахарный сироп, яичный белок' },
{ title: 'Mojito', categoryId: cocktails.id, grams: 300, price: 480, description: 'Ром, лайм, мята, содовая, сахар' },
{ title: 'Long Island', categoryId: cocktails.id, grams: 350, price: 590, description: 'Водка, ром, текила, джин, трипл сек, кола' },
// Soft drinks
{ title: 'Лимонад домашний', categoryId: soft.id, grams: 400, price: 250, description: 'Свежевыжатый лимонад с мятой' },
{ title: 'Морс клюквенный', categoryId: soft.id, grams: 400, price: 220, description: 'Домашний морс из свежей клюквы' },
{ title: 'Сок апельсиновый', categoryId: soft.id, grams: 300, price: 280, description: 'Свежевыжатый апельсиновый сок' },
// Lunch
{ title: 'Ланч: Борщ + хлеб', categoryId: lunch.id, grams: 350, price: 220, description: 'Домашний борщ с пампушкой' },
{ title: 'Ланч: Котлета с пюре', categoryId: lunch.id, grams: 300, price: 280, description: 'Куриная котлета с картофельным пюре и подливой' },
{ title: 'Ланч: Паста болоньезе', categoryId: lunch.id, grams: 300, price: 260, description: 'Спагетти с мясным соусом болоньезе' },
{ title: 'Ланч: Комплекс (суп + горячее + напиток)', categoryId: lunch.id, grams: null, price: 390, description: 'Суп дня, горячее дня, компот' },
];
const createdDishes: any[] = [];
for (const d of dishesData) {
const dish = await prisma.dish.upsert({
where: { id: createdDishes.length + 1 },
update: {},
create: d,
});
createdDishes.push(dish);
}
// ─── Menu (link dishes to pubs, except lunch category) ──
for (const pub of [pub1, pub2, pub3, pub4]) {
for (const dish of createdDishes) {
if (dish.categoryId === lunch.id) continue; // lunch is separate
await prisma.menu.upsert({
where: { pubId_dishId: { pubId: pub.id, dishId: dish.id } },
update: {},
create: { pubId: pub.id, dishId: dish.id },
});
}
}
// ─── Lunch Menu (current week) ─────────────────────
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const lunchDishes = createdDishes.filter(d => d.categoryId === lunch.id);
for (const pub of [pub1, pub2, pub3, pub4]) {
for (const dish of lunchDishes) {
await prisma.lunchMenu.upsert({
where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },
update: {},
create: {
pubId: pub.id,
dishId: dish.id,
weekStart: monday,
weekEnd: sunday,
},
});
}
}
// ─── Billboard ─────────────────────────────────────
const billboards = [
{ title: 'Live Rock Night', artist: 'The Wild Rovers', pubId: pub1.id, datetime: new Date('2026-04-12T20:00:00'), poster: null },
{ title: 'Jazz Friday', artist: 'Smooth Quartet', pubId: pub1.id, datetime: new Date('2026-04-11T19:30:00'), poster: null },
{ title: 'Irish Folk Evening', artist: 'Celtic Storm', pubId: pub1.id, datetime: new Date('2026-04-13T18:00:00'), poster: null },
{ title: 'Stand-Up Comedy', artist: 'Андрей Бебуришвили', pubId: pub2.id, datetime: new Date('2026-04-12T19:00:00'), poster: null },
{ title: 'Karaoke Night', artist: 'DJ Shamrock', pubId: pub2.id, datetime: new Date('2026-04-10T21:00:00'), poster: null },
{ title: 'Acoustic Session', artist: 'Indie Folk Band', pubId: pub3.id, datetime: new Date('2026-04-11T20:00:00'), poster: null },
];
for (const b of billboards) {
await prisma.billboard.create({ data: b });
}
// ─── Admin User ────────────────────────────────────
const hashedPassword = await bcrypt.hash('admin123', 12);
await prisma.user.upsert({
where: { phone: '+79001234567' },
update: {},
create: {
phone: '+79001234567',
password: hashedPassword,
name: 'Администратор',
role: 'ADMIN',
email: 'admin@harats.com',
},
});
// Demo user
const userPassword = await bcrypt.hash('user123', 12);
await prisma.user.upsert({
where: { phone: '+79009876543' },
update: {},
create: {
phone: '+79009876543',
password: userPassword,
name: 'Иван',
surname: 'Петров',
role: 'USER',
email: 'ivan@example.com',
},
});
console.log('✅ Seed complete!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+44
View File
@@ -0,0 +1,44 @@
import express from 'express';
import cors from 'cors';
import { errorHandler } from './middleware/errorHandler.js';
// Routes
import authRoutes from './modules/auth/auth.routes.js';
import userRoutes from './modules/user/user.routes.js';
import pubRoutes from './modules/pub/pub.routes.js';
import menuRoutes from './modules/menu/menu.routes.js';
import dishRoutes from './modules/dish/dish.routes.js';
import tableRoutes from './modules/table/table.routes.js';
import reservationRoutes from './modules/reservation/reservation.routes.js';
import billboardRoutes from './modules/billboard/billboard.routes.js';
import paymentRoutes from './modules/payment/payment.routes.js';
const app = express();
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
}));
app.use(express.json());
// ─── API Routes ──────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/pubs', pubRoutes);
app.use('/api/pubs', menuRoutes); // /api/pubs/:id/menu, /api/pubs/:id/lunch-menu
app.use('/api/pubs', tableRoutes); // /api/pubs/:id/tables
app.use('/api/pubs', billboardRoutes); // /api/pubs/:id/billboard
app.use('/api/dishes', dishRoutes);
app.use('/api/reservations', reservationRoutes);
app.use('/api/payments', paymentRoutes);
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler
app.use(errorHandler);
export default app;
+20
View File
@@ -0,0 +1,20 @@
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
export const env = {
PORT: parseInt(process.env.PORT || '3001', 10),
NODE_ENV: process.env.NODE_ENV || 'development',
DATABASE_URL: process.env.DATABASE_URL!,
JWT_SECRET: process.env.JWT_SECRET!,
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET!,
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '15m',
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || '587', 10),
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
};
+12
View File
@@ -0,0 +1,12 @@
import { env } from './config/env.js';
import app from './app.js';
app.listen(env.PORT, () => {
console.log(`
╔══════════════════════════════════════════╗
║ 🍀 Harat's Booking API ║
║ http://localhost:${env.PORT}
║ Mode: ${env.NODE_ENV.padEnd(28)}
╚══════════════════════════════════════════╝
`);
});
+25
View File
@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt.js';
export interface AuthRequest extends Request {
userId?: number;
userRole?: string;
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: 'Требуется авторизация' });
return;
}
try {
const token = header.split(' ')[1];
const payload = verifyAccessToken(token);
req.userId = payload.userId;
req.userRole = payload.role;
next();
} catch {
res.status(401).json({ success: false, error: 'Недействительный токен' });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
console.error('❌ Error:', err.message);
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'development' ? err.message : 'Внутренняя ошибка сервера',
});
}
+12
View File
@@ -0,0 +1,12 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from './auth.js';
export function roleMiddleware(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.userRole || !roles.includes(req.userRole)) {
res.status(403).json({ success: false, error: 'Недостаточно прав' });
return;
}
next();
};
}
+21
View File
@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
const errors = err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}));
res.status(400).json({ success: false, error: 'Ошибка валидации', details: errors });
return;
}
next(err);
}
};
}
@@ -0,0 +1,38 @@
import { Request, Response } from 'express';
import { AuthService } from './auth.service.js';
const authService = new AuthService();
export class AuthController {
async register(req: Request, res: Response) {
try {
const result = await authService.register(req.body);
res.status(201).json({ success: true, data: result });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async login(req: Request, res: Response) {
try {
const result = await authService.login(req.body);
res.json({ success: true, data: result });
} catch (err: any) {
res.status(401).json({ success: false, error: err.message });
}
}
async refresh(req: Request, res: Response) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({ success: false, error: 'Refresh token обязателен' });
return;
}
const result = await authService.refresh(refreshToken);
res.json({ success: true, data: result });
} catch (err: any) {
res.status(401).json({ success: false, error: 'Невалидный refresh token' });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { AuthController } from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { registerSchema, loginSchema } from './auth.schema.js';
const router = Router();
const controller = new AuthController();
router.post('/register', validate(registerSchema), (req, res) => controller.register(req, res));
router.post('/login', validate(loginSchema), (req, res) => controller.login(req, res));
router.post('/refresh', (req, res) => controller.refresh(req, res));
export default router;
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const registerSchema = z.object({
phone: z.string().min(10).max(20),
password: z.string().min(6).max(100),
name: z.string().min(1).max(100),
surname: z.string().max(100).optional(),
email: z.string().email().optional(),
});
export const loginSchema = z.object({
phone: z.string().min(10).max(20),
password: z.string().min(1),
});
+111
View File
@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
import { hashPassword, comparePassword } from '../../utils/hash.js';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../../utils/jwt.js';
const prisma = new PrismaClient();
interface RegisterInput {
phone: string;
password: string;
name: string;
surname?: string;
email?: string;
}
interface LoginInput {
phone: string;
password: string;
}
export class AuthService {
private extractCardExpiry(cardToken?: string | null): string | null {
if (!cardToken) return null;
try {
const parsed = JSON.parse(cardToken);
return parsed.cardExpiry || null;
} catch {
return null;
}
}
async register(input: RegisterInput) {
const existing = await prisma.user.findUnique({ where: { phone: input.phone } });
if (existing) {
throw new Error('Пользователь с таким номером уже существует');
}
const hashed = await hashPassword(input.password);
const user = await prisma.user.create({
data: {
phone: input.phone,
password: hashed,
name: input.name,
surname: input.surname,
email: input.email,
},
});
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
surname: user.surname,
email: user.email,
role: user.role,
cardLastFour: user.cardLastFour,
cardExpiry: this.extractCardExpiry(user.cardToken),
createdAt: user.createdAt.toISOString(),
},
accessToken,
refreshToken,
};
}
async login(input: LoginInput) {
const user = await prisma.user.findUnique({ where: { phone: input.phone } });
if (!user) {
throw new Error('Неверный номер телефона или пароль');
}
const valid = await comparePassword(input.password, user.password);
if (!valid) {
throw new Error('Неверный номер телефона или пароль');
}
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
surname: user.surname,
email: user.email,
role: user.role,
cardLastFour: user.cardLastFour,
cardExpiry: this.extractCardExpiry(user.cardToken),
createdAt: user.createdAt.toISOString(),
},
accessToken,
refreshToken,
};
}
async refresh(token: string) {
const payload = verifyRefreshToken(token);
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
if (!user) {
throw new Error('Пользователь не найден');
}
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return { accessToken, refreshToken };
}
}
@@ -0,0 +1,12 @@
import { Request, Response } from 'express';
import { BillboardService } from './billboard.service.js';
const billboardService = new BillboardService();
export class BillboardController {
async getByPub(req: Request, res: Response) {
const pubId = Number(req.params.id);
const events = await billboardService.getByPub(pubId);
res.json({ success: true, data: events });
}
}
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { BillboardController } from './billboard.controller.js';
const router = Router();
const controller = new BillboardController();
// Nested under /api/pubs
router.get('/:id/billboard', (req, res) => controller.getByPub(req, res));
export default router;
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class BillboardService {
async getByPub(pubId: number) {
return prisma.billboard.findMany({
where: {
pubId,
datetime: { gte: new Date() },
},
orderBy: { datetime: 'asc' },
});
}
}
@@ -0,0 +1,25 @@
import { Request, Response } from 'express';
import { DishService } from './dish.service.js';
const dishService = new DishService();
export class DishController {
async toggleAvailability(req: Request, res: Response) {
try {
const { availability } = req.body;
if (!['AVAILABLE', 'STOPPED'].includes(availability)) {
res.status(400).json({ success: false, error: 'Статус должен быть AVAILABLE или STOPPED' });
return;
}
const dish = await dishService.toggleAvailability(Number(req.params.id), availability);
res.json({ success: true, data: dish });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async getAll(_req: Request, res: Response) {
const dishes = await dishService.getAll();
res.json({ success: true, data: dishes });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { DishController } from './dish.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
import { roleMiddleware } from '../../middleware/role.js';
const router = Router();
const controller = new DishController();
router.get('/', (req, res) => controller.getAll(req, res));
router.put('/:id/availability', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.toggleAvailability(req, res));
export default router;
+22
View File
@@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class DishService {
async toggleAvailability(dishId: number, availability: 'AVAILABLE' | 'STOPPED') {
const dish = await prisma.dish.update({
where: { id: dishId },
data: { availability },
include: { category: true },
});
return { ...dish, price: Number(dish.price) };
}
async getAll() {
const dishes = await prisma.dish.findMany({
include: { category: true },
orderBy: { title: 'asc' },
});
return dishes.map((d: any) => ({ ...d, price: Number(d.price) }));
}
}
@@ -0,0 +1,18 @@
import { Request, Response } from 'express';
import { MenuService } from './menu.service.js';
const menuService = new MenuService();
export class MenuController {
async getMenu(req: Request, res: Response) {
const pubId = Number(req.params.id);
const menu = await menuService.getMenuByPub(pubId);
res.json({ success: true, data: menu });
}
async getLunchMenu(req: Request, res: Response) {
const pubId = Number(req.params.id);
const menu = await menuService.getLunchMenu(pubId);
res.json({ success: true, data: menu });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { MenuController } from './menu.controller.js';
const router = Router();
const controller = new MenuController();
// These are nested under /api/pubs/:id
router.get('/:id/menu', (req, res) => controller.getMenu(req, res));
router.get('/:id/lunch-menu', (req, res) => controller.getLunchMenu(req, res));
export default router;
+62
View File
@@ -0,0 +1,62 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class MenuService {
async getMenuByPub(pubId: number) {
const items = await prisma.menu.findMany({
where: { pubId },
include: {
dish: {
include: { category: true },
},
},
});
// Group by category
const grouped: Record<string, any[]> = {};
for (const item of items) {
const cat = item.dish.category.title;
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({
id: item.id,
pubId: item.pubId,
dishId: item.dishId,
dish: {
...item.dish,
price: Number(item.dish.price),
category: item.dish.category,
},
});
}
return grouped;
}
async getLunchMenu(pubId: number) {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
monday.setHours(0, 0, 0, 0);
const items = await prisma.lunchMenu.findMany({
where: {
pubId,
weekStart: { lte: now },
weekEnd: { gte: now },
},
include: {
dish: {
include: { category: true },
},
},
});
return items.map(item => ({
...item,
dish: {
...item.dish,
price: Number(item.dish.price),
},
}));
}
}
@@ -0,0 +1,28 @@
import { Request, Response } from 'express';
export class PaymentController {
async createPayment(req: Request, res: Response) {
// Mock payment — in production, integrate with YooKassa/Stripe
const { amount, reservationId } = req.body;
const mockPaymentId = `PAY-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
console.log(`💳 [MOCK PAYMENT] ${amount}₽ for reservation #${reservationId}`);
res.json({
success: true,
data: {
paymentId: mockPaymentId,
status: 'succeeded',
amount,
reservationId,
},
});
}
async webhook(req: Request, res: Response) {
// Mock webhook handler
console.log('📨 [MOCK WEBHOOK]', req.body);
res.json({ success: true });
}
}
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { PaymentController } from './payment.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
const router = Router();
const controller = new PaymentController();
router.post('/create', authMiddleware, (req, res) => controller.createPayment(req, res));
router.post('/webhook', (req, res) => controller.webhook(req, res));
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Request, Response } from 'express';
import { PubService } from './pub.service.js';
const pubService = new PubService();
export class PubController {
async getAll(_req: Request, res: Response) {
const pubs = await pubService.getAll();
res.json({ success: true, data: pubs });
}
async getById(req: Request, res: Response) {
try {
const pub = await pubService.getById(Number(req.params.id));
res.json({ success: true, data: pub });
} catch (err: any) {
res.status(404).json({ success: false, error: err.message });
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { PubController } from './pub.controller.js';
const router = Router();
const controller = new PubController();
router.get('/', (req, res) => controller.getAll(req, res));
router.get('/:id', (req, res) => controller.getById(req, res));
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class PubService {
async getAll() {
return prisma.pub.findMany({ orderBy: { name: 'asc' } });
}
async getById(id: number) {
const pub = await prisma.pub.findUnique({ where: { id } });
if (!pub) throw new Error('Паб не найден');
return pub;
}
}
@@ -0,0 +1,50 @@
import { Response } from 'express';
import { AuthRequest } from '../../middleware/auth.js';
import { ReservationService } from './reservation.service.js';
const reservationService = new ReservationService();
export class ReservationController {
async create(req: AuthRequest, res: Response) {
try {
const reservation = await reservationService.create({
userId: req.userId!,
...req.body,
});
res.status(201).json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async getMy(req: AuthRequest, res: Response) {
const reservations = await reservationService.getMyReservations(req.userId!);
res.json({ success: true, data: reservations });
}
async getAll(req: AuthRequest, res: Response) {
const pubId = req.query.pubId ? Number(req.query.pubId) : undefined;
const date = req.query.date as string | undefined;
const reservations = await reservationService.getAllReservations(pubId, date);
res.json({ success: true, data: reservations });
}
async cancel(req: AuthRequest, res: Response) {
try {
const reservation = await reservationService.cancel(Number(req.params.id), req.userId!);
res.json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async updateStatus(req: AuthRequest, res: Response) {
try {
const { status } = req.body;
const reservation = await reservationService.updateStatus(Number(req.params.id), status);
res.json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
}
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { ReservationController } from './reservation.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
import { roleMiddleware } from '../../middleware/role.js';
import { validate } from '../../middleware/validate.js';
import { createReservationSchema } from './reservation.schema.js';
const router = Router();
const controller = new ReservationController();
router.post('/', authMiddleware, validate(createReservationSchema), (req, res) => controller.create(req, res));
router.get('/my', authMiddleware, (req, res) => controller.getMy(req, res));
router.get('/', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.getAll(req, res));
router.put('/:id/cancel', authMiddleware, (req, res) => controller.cancel(req, res));
router.put('/:id/status', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.updateStatus(req, res));
export default router;
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createReservationSchema = z.object({
pubId: z.number().int().positive(),
tableId: z.number().int().positive(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^\d{2}:\d{2}$/),
numPeople: z.number().int().min(1).max(20),
dishes: z.array(z.object({
dishId: z.number().int().positive(),
quantity: z.number().int().min(1).max(50),
})).optional(),
});
@@ -0,0 +1,209 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { sendReceiptEmail } from '../../utils/email.js';
const prisma = new PrismaClient();
interface CreateReservationInput {
userId: number;
pubId: number;
tableId: number;
date: string;
time: string;
numPeople: number;
dishes?: { dishId: number; quantity: number }[];
}
export class ReservationService {
async create(input: CreateReservationInput) {
// Check table availability
const existing = await prisma.reservation.findFirst({
where: {
tableId: input.tableId,
date: new Date(input.date),
time: new Date(`1970-01-01T${input.time}:00.000Z`),
status: { in: ['PENDING', 'CONFIRMED'] },
},
});
if (existing) {
throw new Error('Этот стол уже забронирован на указанное время');
}
// Calculate cost if dishes are present
let totalCost = new Prisma.Decimal(0);
let dishData: { dishId: number; quantity: number; priceAtOrder: Prisma.Decimal }[] = [];
if (input.dishes && input.dishes.length > 0) {
const dishIds = input.dishes.map(d => d.dishId);
const dishes = await prisma.dish.findMany({
where: { id: { in: dishIds }, availability: 'AVAILABLE' },
});
// Validate all dishes exist and are available
for (const orderDish of input.dishes) {
const dish = dishes.find(d => d.id === orderDish.dishId);
if (!dish) {
throw new Error(`Блюдо #${orderDish.dishId} недоступно или не найдено`);
}
// Check it's not a lunch dish
const isLunch = await prisma.lunchMenu.findFirst({
where: { dishId: orderDish.dishId },
});
if (isLunch) {
throw new Error(`Блюдо "${dish.title}" из ланч-меню не может быть в предзаказе`);
}
const lineTotal = dish.price.mul(orderDish.quantity);
totalCost = totalCost.add(lineTotal);
dishData.push({
dishId: orderDish.dishId,
quantity: orderDish.quantity,
priceAtOrder: dish.price,
});
}
}
const hasDishes = dishData.length > 0;
const prepaid = hasDishes ? totalCost.mul(new Prisma.Decimal(0.5)) : new Prisma.Decimal(0);
const reservation = await prisma.reservation.create({
data: {
userId: input.userId,
pubId: input.pubId,
tableId: input.tableId,
date: new Date(input.date),
time: new Date(`1970-01-01T${input.time}:00.000Z`),
numPeople: input.numPeople,
status: hasDishes ? 'PENDING' : 'CONFIRMED',
totalCost: hasDishes ? totalCost : null,
prepaid: hasDishes ? prepaid : null,
dishes: {
create: dishData,
},
},
include: {
pub: true,
table: true,
dishes: {
include: { dish: true },
},
user: true,
},
});
// Send receipt email if user has email
if (reservation.user.email) {
try {
await sendReceiptEmail({
to: reservation.user.email,
reservationId: reservation.id,
pubName: reservation.pub.name,
date: input.date,
time: input.time,
table: reservation.table.number,
dishes: reservation.dishes.map(d => ({
title: d.dish.title,
quantity: d.quantity,
price: Number(d.priceAtOrder),
})),
totalCost: Number(totalCost),
prepaid: Number(prepaid),
});
} catch (e) {
console.error('Failed to send email:', e);
}
}
return this.formatReservation(reservation);
}
async getMyReservations(userId: number) {
const reservations = await prisma.reservation.findMany({
where: { userId },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
orderBy: { createdAt: 'desc' },
});
return reservations.map(this.formatReservation);
}
async getAllReservations(pubId?: number, date?: string) {
const where: any = {};
if (pubId) where.pubId = pubId;
if (date) where.date = new Date(date);
const reservations = await prisma.reservation.findMany({
where,
include: {
pub: true,
table: true,
user: { select: { id: true, name: true, surname: true, phone: true } },
dishes: { include: { dish: true } },
},
orderBy: { createdAt: 'desc' },
});
return reservations.map(this.formatReservation);
}
async cancel(reservationId: number, userId: number) {
const reservation = await prisma.reservation.findUnique({ where: { id: reservationId } });
if (!reservation) throw new Error('Бронь не найдена');
if (reservation.userId !== userId) throw new Error('Нет доступа к этой брони');
if (!['PENDING', 'CONFIRMED'].includes(reservation.status)) {
throw new Error('Эту бронь нельзя отменить');
}
const updated = await prisma.reservation.update({
where: { id: reservationId },
data: { status: 'CANCELLED' },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
});
return this.formatReservation(updated);
}
async updateStatus(reservationId: number, status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') {
const updated = await prisma.reservation.update({
where: { id: reservationId },
data: { status },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
});
return this.formatReservation(updated);
}
private formatReservation(r: any) {
return {
id: r.id,
userId: r.userId,
user: r.user ? { id: r.user.id, name: r.user.name, surname: r.user.surname, phone: r.user.phone } : undefined,
pubId: r.pubId,
pub: r.pub,
tableId: r.tableId,
table: r.table,
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
time: r.time instanceof Date ? r.time.toISOString().split('T')[1].substring(0, 5) : r.time,
numPeople: r.numPeople,
status: r.status,
totalCost: r.totalCost ? Number(r.totalCost) : null,
prepaid: r.prepaid ? Number(r.prepaid) : null,
dishes: r.dishes?.map((d: any) => ({
id: d.id,
dishId: d.dishId,
dish: d.dish ? { ...d.dish, price: Number(d.dish.price) } : undefined,
quantity: d.quantity,
priceAtOrder: Number(d.priceAtOrder),
})) || [],
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
};
}
}
@@ -0,0 +1,23 @@
import { Request, Response } from 'express';
import { TableService } from './table.service.js';
const tableService = new TableService();
export class TableController {
async getByPub(req: Request, res: Response) {
const pubId = Number(req.params.id);
const tables = await tableService.getByPub(pubId);
res.json({ success: true, data: tables });
}
async getAvailability(req: Request, res: Response) {
const pubId = Number(req.params.id);
const { date, time } = req.query as { date: string; time: string };
if (!date || !time) {
res.status(400).json({ success: false, error: 'Параметры date и time обязательны' });
return;
}
const tables = await tableService.getAvailability(pubId, date, time);
res.json({ success: true, data: tables });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { TableController } from './table.controller.js';
const router = Router();
const controller = new TableController();
// Mounted under /api/pubs
router.get('/:id/tables', (req, res) => controller.getByPub(req, res));
router.get('/:id/tables/availability', (req, res) => controller.getAvailability(req, res));
export default router;
+36
View File
@@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class TableService {
async getByPub(pubId: number) {
return prisma.table.findMany({
where: { pubId },
orderBy: { number: 'asc' },
});
}
async getAvailability(pubId: number, date: string, time: string) {
const tables = await prisma.table.findMany({
where: { pubId },
orderBy: { number: 'asc' },
});
const reservedTables = await prisma.reservation.findMany({
where: {
pubId,
date: new Date(date),
time: new Date(`1970-01-01T${time}:00.000Z`),
status: { in: ['PENDING', 'CONFIRMED'] },
},
select: { tableId: true },
});
const reservedIds = new Set(reservedTables.map(r => r.tableId));
return tables.map(table => ({
...table,
isAvailable: !reservedIds.has(table.id),
}));
}
}
@@ -0,0 +1,45 @@
import { Response } from 'express';
import { AuthRequest } from '../../middleware/auth.js';
import { UserService } from './user.service.js';
const userService = new UserService();
export class UserController {
async getProfile(req: AuthRequest, res: Response) {
try {
const profile = await userService.getProfile(req.userId!);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(404).json({ success: false, error: err.message });
}
}
async updateProfile(req: AuthRequest, res: Response) {
try {
const profile = await userService.updateProfile(req.userId!, req.body);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async linkCard(req: AuthRequest, res: Response) {
try {
const { cardNumber, cardExpiry, cardCvv, currentPassword } = req.body;
const profile = await userService.linkCard(req.userId!, { cardNumber, cardExpiry, cardCvv, currentPassword });
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async unlinkCard(req: AuthRequest, res: Response) {
try {
const { currentPassword } = req.body;
const profile = await userService.unlinkCard(req.userId!, currentPassword);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { UserController } from './user.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
const router = Router();
const controller = new UserController();
router.get('/me', authMiddleware, (req, res) => controller.getProfile(req, res));
router.put('/me', authMiddleware, (req, res) => controller.updateProfile(req, res));
router.put('/me/card', authMiddleware, (req, res) => controller.linkCard(req, res));
router.delete('/me/card', authMiddleware, (req, res) => controller.unlinkCard(req, res));
export default router;
+117
View File
@@ -0,0 +1,117 @@
import { PrismaClient } from '@prisma/client';
import { comparePassword } from '../../utils/hash.js';
const prisma = new PrismaClient();
export class UserService {
private parseCardToken(cardToken?: string | null): { cardExpiry?: string } {
if (!cardToken) return {};
try {
const parsed = JSON.parse(cardToken);
return { cardExpiry: parsed.cardExpiry };
} catch {
return {};
}
}
private sanitizeProfile(user: any) {
const { password, cardToken, ...profile } = user;
const cardMeta = this.parseCardToken(cardToken);
return {
...profile,
cardExpiry: cardMeta.cardExpiry || null,
};
}
async getProfile(userId: number) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('Пользователь не найден');
return this.sanitizeProfile(user);
}
async updateProfile(
userId: number,
data: { name?: string; surname?: string; email?: string; phone?: string; password?: string }
) {
const updateData: any = {};
if (data.name) updateData.name = data.name;
if (data.surname !== undefined) updateData.surname = data.surname;
if (data.email !== undefined) updateData.email = data.email;
if (data.phone) updateData.phone = data.phone;
if (data.password) {
const { hashPassword } = await import('../../utils/hash.js');
updateData.password = await hashPassword(data.password);
}
try {
const user = await prisma.user.update({ where: { id: userId }, data: updateData });
return this.sanitizeProfile(user);
} catch (err: any) {
if (err?.code === 'P2002') {
throw new Error('Пользователь с таким телефоном уже существует');
}
throw err;
}
}
async linkCard(
userId: number,
payload: { cardNumber: string; cardExpiry: string; cardCvv: string; currentPassword?: string }
) {
const userBeforeUpdate = await prisma.user.findUnique({ where: { id: userId } });
if (!userBeforeUpdate) throw new Error('Пользователь не найден');
const cardDigits = payload.cardNumber.replace(/\D/g, '');
if (cardDigits.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('CVV/CVC должен содержать 3 или 4 цифры');
}
const hasLinkedCard = Boolean(userBeforeUpdate.cardLastFour);
if (hasLinkedCard) {
if (!payload.currentPassword) {
throw new Error('Для изменения карты требуется пароль от аккаунта');
}
const isValidPassword = await comparePassword(payload.currentPassword, userBeforeUpdate.password);
if (!isValidPassword) {
throw new Error('Неверный пароль');
}
}
const cardToken = JSON.stringify({
cardNumber: cardDigits,
cardExpiry: payload.cardExpiry,
cardCvv: payload.cardCvv,
updatedAt: new Date().toISOString(),
});
const user = await prisma.user.update({
where: { id: userId },
data: { cardLastFour: cardDigits.slice(-4), cardToken },
});
return this.sanitizeProfile(user);
}
async unlinkCard(userId: number, currentPassword: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('Пользователь не найден');
if (!user.cardLastFour) throw new Error('Карта не привязана');
if (!currentPassword) throw new Error('Для удаления карты требуется пароль от аккаунта');
const isValidPassword = await comparePassword(currentPassword, user.password);
if (!isValidPassword) {
throw new Error('Неверный пароль');
}
const updated = await prisma.user.update({
where: { id: userId },
data: { cardLastFour: null, cardToken: null },
});
return this.sanitizeProfile(updated);
}
}
+71
View File
@@ -0,0 +1,71 @@
import nodemailer from 'nodemailer';
import { env } from '../config/env.js';
const transporter = env.SMTP_USER
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
})
: null;
interface EmailReceipt {
to: string;
reservationId: number;
pubName: string;
date: string;
time: string;
table: number;
dishes: { title: string; quantity: number; price: number }[];
totalCost: number;
prepaid: number;
}
export async function sendReceiptEmail(data: EmailReceipt): Promise<void> {
if (!transporter) {
console.log(`📧 [MOCK EMAIL] Receipt for reservation #${data.reservationId}${data.to}`);
console.log(` Pub: ${data.pubName}, Date: ${data.date}, Time: ${data.time}, Table: ${data.table}`);
if (data.dishes.length) {
console.log(` Dishes: ${data.dishes.map(d => `${d.title} x${d.quantity}`).join(', ')}`);
console.log(` Total: ${data.totalCost}₽, Prepaid: ${data.prepaid}`);
}
return;
}
const dishRows = data.dishes
.map(d => `<tr><td>${d.title}</td><td>${d.quantity}</td><td>${d.price}₽</td></tr>`)
.join('');
const html = `
<div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#1a1a1a;color:#f0efe8;padding:32px;border-radius:12px;">
<h1 style="color:#c8a951;text-align:center;">Harat's Irish Pub</h1>
<h2 style="color:#2eaa66;">Бронирование подтверждено</h2>
<p><strong>Номер брони:</strong> #${data.reservationId}</p>
<p><strong>Паб:</strong> ${data.pubName}</p>
<p><strong>Дата:</strong> ${data.date}</p>
<p><strong>Время:</strong> ${data.time}</p>
<p><strong>Стол:</strong> №${data.table}</p>
${data.dishes.length ? `
<h3 style="color:#c8a951;">Предзаказ блюд</h3>
<table style="width:100%;border-collapse:collapse;color:#f0efe8;">
<tr style="border-bottom:1px solid #333;"><th style="text-align:left;padding:8px;">Блюдо</th><th>Кол-во</th><th>Цена</th></tr>
${dishRows}
<tr style="border-top:2px solid #c8a951;"><td colspan="2" style="padding:8px;"><strong>Итого:</strong></td><td><strong>${data.totalCost}₽</strong></td></tr>
<tr><td colspan="2" style="padding:8px;"><strong>Предоплата:</strong></td><td><strong>${data.prepaid}₽</strong></td></tr>
</table>
` : ''}
<p style="color:#a09e94;margin-top:24px;font-size:12px;">Это автоматическое письмо. По вопросам: 8 (800) 775-03-39</p>
</div>
`;
await transporter.sendMail({
from: `"Harat's Booking" <${env.SMTP_USER}>`,
to: data.to,
subject: `Бронь #${data.reservationId} — Harat's Irish Pub`,
html,
});
}
+11
View File
@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
+23
View File
@@ -0,0 +1,23 @@
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
interface TokenPayload {
userId: number;
role: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN });
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_REFRESH_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRES_IN });
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
}
export function verifyRefreshToken(token: string): TokenPayload {
return jwt.verify(token, env.JWT_REFRESH_SECRET) as TokenPayload;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src", "prisma/seed.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"build": "",
"buildDate": ""
}