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