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