Initial commit: HotelSync PMS v0.1.0

- React 18 + TypeScript + Vite + Tailwind CSS
- Шахматка бронирований (drag-to-book)
- Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings
- Роли: super_admin, hotel_manager, housekeeper
- Светлая/тёмная тема
- Docker + Nginx конфигурация
- Лендинг hotelsync.ru
This commit is contained in:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

254
src/pages/ApiDocsPage.tsx Normal file
View File

@@ -0,0 +1,254 @@
import { useState } from 'react'
import { Copy, CheckCheck, ChevronDown, ChevronRight, Lock, Globe } from 'lucide-react'
import { cn } from '../lib/utils'
import type { ApiEndpoint } from '../types'
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
PUT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
}
const API_ENDPOINTS: ApiEndpoint[] = [
// Auth
{ method: 'POST', path: '/auth/login', summary: 'Авторизация', tags: ['Auth'], auth: false, requestBody: '{ "email": "string", "password": "string" }', response: '{ "token": "string", "user": {...} }' },
{ method: 'POST', path: '/auth/refresh', summary: 'Обновить токен', tags: ['Auth'], auth: true },
{ method: 'POST', path: '/auth/logout', summary: 'Выход', tags: ['Auth'], auth: true },
// Hotels
{ method: 'GET', path: '/hotels', summary: 'Список отелей', tags: ['Hotels'], auth: true, roles: ['super_admin'], response: '{ "data": Hotel[], "total": number }' },
{ method: 'POST', path: '/hotels', summary: 'Создать отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
{ method: 'GET', path: '/hotels/:hotelId', summary: 'Получить отель', tags: ['Hotels'], auth: true },
{ method: 'PATCH', path: '/hotels/:hotelId', summary: 'Обновить отель', tags: ['Hotels'], auth: true, roles: ['super_admin', 'hotel_manager'] },
{ method: 'DELETE', path: '/hotels/:hotelId', summary: 'Удалить отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
// Rooms
{ method: 'GET', path: '/hotels/:hotelId/rooms', summary: 'Список номеров', tags: ['Rooms'], auth: true },
{ method: 'POST', path: '/hotels/:hotelId/rooms', summary: 'Создать номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
{ method: 'PATCH', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Обновить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
{ method: 'DELETE', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Удалить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
// Bookings
{ method: 'GET', path: '/hotels/:hotelId/bookings', summary: 'Список бронирований (с фильтрацией по датам)', tags: ['Bookings'], auth: true, response: '{ "data": Booking[], "total": number }' },
{ method: 'POST', path: '/hotels/:hotelId/bookings', summary: 'Создать бронирование', tags: ['Bookings'], auth: true, requestBody: '{ "roomId": "string", "guestName": "string", "checkIn": "YYYY-MM-DD", "checkOut": "YYYY-MM-DD" }' },
{ method: 'GET', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Получить бронирование', tags: ['Bookings'], auth: true },
{ method: 'PATCH', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Обновить бронирование', tags: ['Bookings'], auth: true },
{ method: 'DELETE', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Отменить бронирование', tags: ['Bookings'], auth: true },
// Channels
{ method: 'GET', path: '/hotels/:hotelId/channels', summary: 'Список каналов', tags: ['Channels'], auth: true },
{ method: 'PATCH', path: '/hotels/:hotelId/channels/:channelId', summary: 'Настроить канал', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
{ method: 'POST', path: '/hotels/:hotelId/channels/:channelId/sync', summary: 'Запустить синхронизацию', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
// Housekeeping
{ method: 'GET', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Список задач', tags: ['Housekeeping'], auth: true },
{ method: 'POST', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Создать задачу', tags: ['Housekeeping'], auth: true },
{ method: 'PATCH', path: '/hotels/:hotelId/housekeeping/tasks/:taskId', summary: 'Обновить задачу', tags: ['Housekeeping'], auth: true },
// Webhooks
{ method: 'POST', path: '/webhooks/booking-com', summary: 'Webhook Booking.com', tags: ['Webhooks'], auth: false, description: 'Endpoint для получения событий от Booking.com' },
{ method: 'POST', path: '/webhooks/airbnb', summary: 'Webhook Airbnb', tags: ['Webhooks'], auth: false },
]
const ALL_TAGS = [...new Set(API_ENDPOINTS.flatMap(e => e.tags))]
export function ApiDocsPage() {
const [activeTag, setActiveTag] = useState<string>('Bookings')
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const filtered = API_ENDPOINTS.filter(e => e.tags.includes(activeTag))
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text)
setCopied(key)
setTimeout(() => setCopied(null), 1500)
}
return (
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-5">
{/* Header */}
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center">
<Globe size={18} className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">HotelSync API</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">REST API v1.0 · Base URL: <code className="text-brand-600 dark:text-brand-400">https://api.hotelsync.io/v1</code></p>
</div>
</div>
</div>
{/* Auth info */}
<div className="card p-4 border-brand-200 dark:border-brand-800/50 bg-brand-50/50 dark:bg-brand-900/10">
<div className="flex gap-3">
<Lock size={16} className="text-brand-600 dark:text-brand-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium text-brand-800 dark:text-brand-300 text-sm">Аутентификация</p>
<p className="text-sm text-brand-700 dark:text-brand-400">
Все запросы (кроме <code>/auth/login</code> и webhooks) требуют заголовка:
</p>
<div className="flex items-center gap-2 mt-2">
<code className="text-xs bg-white dark:bg-slate-800 border border-brand-200 dark:border-brand-700 text-brand-700 dark:text-brand-300 px-3 py-1.5 rounded-lg flex-1">
Authorization: Bearer &lt;access_token&gt;
</code>
<button
onClick={() => copy('Authorization: Bearer <access_token>', 'auth')}
className="btn-ghost p-1.5 text-brand-600"
>
{copied === 'auth' ? <CheckCheck size={14} /> : <Copy size={14} />}
</button>
</div>
<p className="text-xs text-brand-600 dark:text-brand-400 mt-1">
Также требуется: <code>X-Hotel-Id: &lt;hotelId&gt;</code> для отельных запросов
</p>
</div>
</div>
</div>
{/* Tag nav */}
<div className="flex flex-wrap gap-1.5">
{ALL_TAGS.map(tag => (
<button
key={tag}
onClick={() => setActiveTag(tag)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
activeTag === tag
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{tag}
<span className="ml-1.5 text-xs opacity-60">
{API_ENDPOINTS.filter(e => e.tags.includes(tag)).length}
</span>
</button>
))}
</div>
{/* Endpoints */}
<div className="space-y-2">
{filtered.map((endpoint, i) => {
const key = `${endpoint.method}-${endpoint.path}`
const isOpen = expandedEndpoint === key
return (
<div key={key} className="card overflow-hidden">
<button
onClick={() => setExpandedEndpoint(isOpen ? null : key)}
className="w-full flex items-center gap-3 px-4 py-3.5 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<span className={cn('badge font-mono font-bold w-16 justify-center shrink-0', METHOD_COLORS[endpoint.method])}>
{endpoint.method}
</span>
<code className="text-sm text-slate-700 dark:text-slate-300 flex-1">
{endpoint.path}
</code>
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:block">
{endpoint.summary}
</span>
{!endpoint.auth && (
<span className="badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 text-[10px]">
Public
</span>
)}
{isOpen ? <ChevronDown size={14} className="text-slate-400 shrink-0" /> : <ChevronRight size={14} className="text-slate-400 shrink-0" />}
</button>
{isOpen && (
<div className="px-4 pb-4 pt-0 border-t border-slate-100 dark:border-slate-700 space-y-3 animate-fade-in">
<p className="text-sm text-slate-600 dark:text-slate-400 pt-3">
{endpoint.description ?? endpoint.summary}
</p>
{endpoint.roles && (
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">Доступные роли</p>
<div className="flex gap-1.5">
{endpoint.roles.map(r => (
<span key={r} className="badge bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300">
{r}
</span>
))}
</div>
</div>
)}
{endpoint.requestBody && (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Request Body</p>
<button onClick={() => copy(endpoint.requestBody!, key + '-req')} className="btn-ghost p-1 text-xs gap-1">
{copied === key + '-req' ? <CheckCheck size={11} /> : <Copy size={11} />}
Копировать
</button>
</div>
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-emerald-400 rounded-lg p-3 overflow-x-auto">
{endpoint.requestBody}
</pre>
</div>
)}
{endpoint.response && (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Response 200</p>
<button onClick={() => copy(endpoint.response!, key + '-res')} className="btn-ghost p-1 text-xs gap-1">
{copied === key + '-res' ? <CheckCheck size={11} /> : <Copy size={11} />}
Копировать
</button>
</div>
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-blue-400 rounded-lg p-3 overflow-x-auto">
{endpoint.response}
</pre>
</div>
)}
{/* Try it */}
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">cURL пример</p>
<div className="flex items-start gap-2">
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-slate-300 rounded-lg p-3 overflow-x-auto flex-1 whitespace-pre-wrap">
{`curl -X ${endpoint.method} \\
https://api.hotelsync.io/v1${endpoint.path} \\${endpoint.auth ? `
-H "Authorization: Bearer <token>" \\
-H "X-Hotel-Id: <hotelId>" \\` : ''}
-H "Content-Type: application/json"`}
</pre>
<button
onClick={() => copy(`curl -X ${endpoint.method} https://api.hotelsync.io/v1${endpoint.path}`, key + '-curl')}
className="btn-ghost p-1.5 shrink-0"
>
{copied === key + '-curl' ? <CheckCheck size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
{/* Rate limits */}
<div className="card p-5">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3">Лимиты запросов</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
{[
{ plan: 'Starter', limit: '1 000 / час', color: 'text-slate-600 dark:text-slate-400' },
{ plan: 'Pro', limit: '10 000 / час', color: 'text-brand-600 dark:text-brand-400' },
{ plan: 'Enterprise', limit: 'Неограничено', color: 'text-amber-600 dark:text-amber-400' },
].map(r => (
<div key={r.plan} className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<p className="font-bold text-slate-900 dark:text-slate-100">{r.plan}</p>
<p className={cn('font-semibold', r.color)}>{r.limit}</p>
</div>
))}
</div>
</div>
</div>
)
}