From c97f7fd1a878fd0c5627f741ba39be2bd43b2225 Mon Sep 17 00:00:00 2001 From: Eugenius-Anonymous Date: Mon, 8 Jun 2026 14:36:51 +0700 Subject: [PATCH] Initial commit: Harat's Booking monorepo with deploy setup --- .dockerignore | 9 + .env.example | 21 + .gitignore | 41 + Dockerfile | 73 + README.md | 73 + client/index.html | 16 + client/package.json | 29 + client/src/App.tsx | 39 + client/src/components/Button.tsx | 47 + client/src/components/Footer.tsx | 56 + client/src/components/Header.tsx | 82 + client/src/components/Input.tsx | 27 + client/src/components/Loader.tsx | 12 + client/src/components/Modal.tsx | 37 + .../features/auth/components/LoginModal.tsx | 69 + .../auth/components/RegisterModal.tsx | 104 + client/src/features/auth/store.ts | 75 + .../booking/components/DishSelector.tsx | 88 + .../features/booking/components/FloorPlan.tsx | 140 + client/src/features/booking/store.ts | 86 + client/src/index.css | 709 +++ client/src/layouts/MainLayout.tsx | 16 + client/src/main.tsx | 10 + client/src/pages/AdminPage.tsx | 238 + client/src/pages/BookingPage.tsx | 384 ++ client/src/pages/HomePage.tsx | 213 + client/src/pages/ProfilePage.tsx | 487 ++ client/src/router.tsx | 19 + client/src/services/api.ts | 45 + client/tsconfig.json | 18 + client/tsconfig.node.json | 18 + client/vite.config.d.ts | 2 + client/vite.config.ts | 22 + deploy/deploy-remote.sh | 83 + deploy/deploy.ps1 | 261 + docker-compose.prod.yml | 44 + ecosystem.config.cjs | 49 + envs/prod/.env.example | 21 + package-lock.json | 4615 +++++++++++++++++ package.json | 25 + packages/shared/package.json | 10 + packages/shared/src/index.ts | 180 + packages/shared/tsconfig.json | 8 + server/package.json | 36 + .../migrations/20260408162910/migration.sql | 193 + server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 155 + server/prisma/seed.ts | 282 + server/src/app.ts | 44 + server/src/config/env.ts | 20 + server/src/index.ts | 12 + server/src/middleware/auth.ts | 25 + server/src/middleware/errorHandler.ts | 12 + server/src/middleware/role.ts | 12 + server/src/middleware/validate.ts | 21 + server/src/modules/auth/auth.controller.ts | 38 + server/src/modules/auth/auth.routes.ts | 13 + server/src/modules/auth/auth.schema.ts | 14 + server/src/modules/auth/auth.service.ts | 111 + .../modules/billboard/billboard.controller.ts | 12 + .../src/modules/billboard/billboard.routes.ts | 10 + .../modules/billboard/billboard.service.ts | 15 + server/src/modules/dish/dish.controller.ts | 25 + server/src/modules/dish/dish.routes.ts | 12 + server/src/modules/dish/dish.service.ts | 22 + server/src/modules/menu/menu.controller.ts | 18 + server/src/modules/menu/menu.routes.ts | 11 + server/src/modules/menu/menu.service.ts | 62 + .../src/modules/payment/payment.controller.ts | 28 + server/src/modules/payment/payment.routes.ts | 11 + server/src/modules/pub/pub.controller.ts | 20 + server/src/modules/pub/pub.routes.ts | 10 + server/src/modules/pub/pub.service.ts | 15 + .../reservation/reservation.controller.ts | 50 + .../modules/reservation/reservation.routes.ts | 17 + .../modules/reservation/reservation.schema.ts | 13 + .../reservation/reservation.service.ts | 209 + server/src/modules/table/table.controller.ts | 23 + server/src/modules/table/table.routes.ts | 11 + server/src/modules/table/table.service.ts | 36 + server/src/modules/user/user.controller.ts | 45 + server/src/modules/user/user.routes.ts | 13 + server/src/modules/user/user.service.ts | 117 + server/src/utils/email.ts | 71 + server/src/utils/hash.ts | 11 + server/src/utils/jwt.ts | 23 + server/tsconfig.json | 12 + tsconfig.base.json | 17 + version.json | 5 + 89 files changed, 10536 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Button.tsx create mode 100644 client/src/components/Footer.tsx create mode 100644 client/src/components/Header.tsx create mode 100644 client/src/components/Input.tsx create mode 100644 client/src/components/Loader.tsx create mode 100644 client/src/components/Modal.tsx create mode 100644 client/src/features/auth/components/LoginModal.tsx create mode 100644 client/src/features/auth/components/RegisterModal.tsx create mode 100644 client/src/features/auth/store.ts create mode 100644 client/src/features/booking/components/DishSelector.tsx create mode 100644 client/src/features/booking/components/FloorPlan.tsx create mode 100644 client/src/features/booking/store.ts create mode 100644 client/src/index.css create mode 100644 client/src/layouts/MainLayout.tsx create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/AdminPage.tsx create mode 100644 client/src/pages/BookingPage.tsx create mode 100644 client/src/pages/HomePage.tsx create mode 100644 client/src/pages/ProfilePage.tsx create mode 100644 client/src/router.tsx create mode 100644 client/src/services/api.ts create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite.config.d.ts create mode 100644 client/vite.config.ts create mode 100644 deploy/deploy-remote.sh create mode 100644 deploy/deploy.ps1 create mode 100644 docker-compose.prod.yml create mode 100644 ecosystem.config.cjs create mode 100644 envs/prod/.env.example create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 server/package.json create mode 100644 server/prisma/migrations/20260408162910/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma create mode 100644 server/prisma/seed.ts create mode 100644 server/src/app.ts create mode 100644 server/src/config/env.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/middleware/errorHandler.ts create mode 100644 server/src/middleware/role.ts create mode 100644 server/src/middleware/validate.ts create mode 100644 server/src/modules/auth/auth.controller.ts create mode 100644 server/src/modules/auth/auth.routes.ts create mode 100644 server/src/modules/auth/auth.schema.ts create mode 100644 server/src/modules/auth/auth.service.ts create mode 100644 server/src/modules/billboard/billboard.controller.ts create mode 100644 server/src/modules/billboard/billboard.routes.ts create mode 100644 server/src/modules/billboard/billboard.service.ts create mode 100644 server/src/modules/dish/dish.controller.ts create mode 100644 server/src/modules/dish/dish.routes.ts create mode 100644 server/src/modules/dish/dish.service.ts create mode 100644 server/src/modules/menu/menu.controller.ts create mode 100644 server/src/modules/menu/menu.routes.ts create mode 100644 server/src/modules/menu/menu.service.ts create mode 100644 server/src/modules/payment/payment.controller.ts create mode 100644 server/src/modules/payment/payment.routes.ts create mode 100644 server/src/modules/pub/pub.controller.ts create mode 100644 server/src/modules/pub/pub.routes.ts create mode 100644 server/src/modules/pub/pub.service.ts create mode 100644 server/src/modules/reservation/reservation.controller.ts create mode 100644 server/src/modules/reservation/reservation.routes.ts create mode 100644 server/src/modules/reservation/reservation.schema.ts create mode 100644 server/src/modules/reservation/reservation.service.ts create mode 100644 server/src/modules/table/table.controller.ts create mode 100644 server/src/modules/table/table.routes.ts create mode 100644 server/src/modules/table/table.service.ts create mode 100644 server/src/modules/user/user.controller.ts create mode 100644 server/src/modules/user/user.routes.ts create mode 100644 server/src/modules/user/user.service.ts create mode 100644 server/src/utils/email.ts create mode 100644 server/src/utils/hash.ts create mode 100644 server/src/utils/jwt.ts create mode 100644 server/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 version.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c078652 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# Exclude secrets and dev artifacts from Docker context +.env +server/.env +**/.env.local +**/node_modules +.git +.vscode +.cursor* +*.tar.gz diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..954e57a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5d8572 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a8081a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a11437c --- /dev/null +++ b/README.md @@ -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 для прямого управления записями БД в браузере. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..e1f11ad --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + Harat's Irish Pub — Бронирование + + + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..8d59aff --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..6379879 --- /dev/null +++ b/client/src/App.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + + + ); +}; + +export default App; diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx new file mode 100644 index 0000000..c616ed0 --- /dev/null +++ b/client/src/components/Button.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'gold' | 'outline' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; + isLoading?: boolean; +} + +export const Button = React.forwardRef( + ({ 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.displayName = 'Button'; diff --git a/client/src/components/Footer.tsx b/client/src/components/Footer.tsx new file mode 100644 index 0000000..d679402 --- /dev/null +++ b/client/src/components/Footer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +export const Footer: React.FC = () => { + return ( + + ); +}; diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx new file mode 100644 index 0000000..39ed7ca --- /dev/null +++ b/client/src/components/Header.tsx @@ -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 ( + <> +
+
+
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ +
+ + + Harat's + + + Irish Pub + + +
+ +
+ {user?.role === 'ADMIN' && ( + + )} +
+
+
+ + setLoginOpen(false)} + onSwitchToRegister={openRegister} + /> + + setRegisterOpen(false)} + onSwitchToLogin={openLogin} + /> + + ); +}; diff --git a/client/src/components/Input.tsx b/client/src/components/Input.tsx new file mode 100644 index 0000000..68541b6 --- /dev/null +++ b/client/src/components/Input.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input = React.forwardRef( + ({ label, error, className = '', id, ...props }, ref) => { + const inputId = id || Math.random().toString(36).substr(2, 9); + + return ( +
+ {label && } + + {error && {error}} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/client/src/components/Loader.tsx b/client/src/components/Loader.tsx new file mode 100644 index 0000000..ebbed46 --- /dev/null +++ b/client/src/components/Loader.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const Loader: React.FC = () => { + return ( +
+ + + + +
+ ); +}; diff --git a/client/src/components/Modal.tsx b/client/src/components/Modal.tsx new file mode 100644 index 0000000..5137012 --- /dev/null +++ b/client/src/components/Modal.tsx @@ -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 = ({ isOpen, onClose, title, children, footer, wide }) => { + if (!isOpen) return null; + + return ( +
{ if(e.target === e.currentTarget) onClose(); }}> +
+
+

{title}

+ +
+
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; diff --git a/client/src/features/auth/components/LoginModal.tsx b/client/src/features/auth/components/LoginModal.tsx new file mode 100644 index 0000000..6caf787 --- /dev/null +++ b/client/src/features/auth/components/LoginModal.tsx @@ -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 = ({ 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 ( + +
+ {error &&
{error}
} + + setPhone(e.target.value)} + required + /> + + setPassword(e.target.value)} + required + /> + + + +

+ Нет аккаунта?{' '} + +

+
+
+ ); +}; diff --git a/client/src/features/auth/components/RegisterModal.tsx b/client/src/features/auth/components/RegisterModal.tsx new file mode 100644 index 0000000..c58d213 --- /dev/null +++ b/client/src/features/auth/components/RegisterModal.tsx @@ -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 = ({ 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) => { + setFormData(prev => ({ ...prev, [e.target.name]: e.target.value })); + }; + + return ( + +
+ {error &&
{error}
} + + + +
+ + +
+ + + + + + + +

+ Уже есть аккаунт?{' '} + +

+
+
+ ); +}; diff --git a/client/src/features/auth/store.ts b/client/src/features/auth/store.ts new file mode 100644 index 0000000..955fa50 --- /dev/null +++ b/client/src/features/auth/store.ts @@ -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; + register: (data: { phone: string; password: string; name: string; surname?: string; email?: string }) => Promise; + logout: () => void; + loadUser: () => Promise; + updateUser: (data: Partial) => void; +} + +export const useAuthStore = create((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, + })); + }, +})); diff --git a/client/src/features/booking/components/DishSelector.tsx b/client/src/features/booking/components/DishSelector.tsx new file mode 100644 index 0000000..a900042 --- /dev/null +++ b/client/src/features/booking/components/DishSelector.tsx @@ -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 = ({ 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 ; + + const menu: Record = menuRes?.data || {}; + + return ( +
+ {Object.entries(menu).map(([category, items]) => ( +
+

+ {category} +

+
+ {items.map((item: any) => { + const dish: IDish = item.dish; + const inCart = cartDishes.find(d => d.dishId === dish.id); + const isAvailable = dish.availability === 'AVAILABLE'; + + return ( +
+
+
+ {dish.title} + {dish.price} ₽ +
+

{dish.description}

+
+ +
+ {inCart ? ( +
+ + {inCart.quantity} + +
+ ) : ( + + )} +
+
+ ); + })} +
+
+ ))} +
+ ); +}; diff --git a/client/src/features/booking/components/FloorPlan.tsx b/client/src/features/booking/components/FloorPlan.tsx new file mode 100644 index 0000000..36ab3fa --- /dev/null +++ b/client/src/features/booking/components/FloorPlan.tsx @@ -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 = ({ tables, selectedTableId, onSelectTable }) => { + const containerRef = useRef(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 ( +
+ + + {/* Background zones (Optional decorative) */} + + + + + + {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 ( + 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' ? ( + + ) : ( + + )} + + + + ); + })} + + +
+ ); +}; diff --git a/client/src/features/booking/store.ts b/client/src/features/booking/store.ts new file mode 100644 index 0000000..dcf4419 --- /dev/null +++ b/client/src/features/booking/store.ts @@ -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((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), +})); diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..30dbd7b --- /dev/null +++ b/client/src/index.css @@ -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"); +} diff --git a/client/src/layouts/MainLayout.tsx b/client/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..ece684c --- /dev/null +++ b/client/src/layouts/MainLayout.tsx @@ -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 ( + <> +
+
+ +
+