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:
254
src/pages/ApiDocsPage.tsx
Normal file
254
src/pages/ApiDocsPage.tsx
Normal 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 <access_token>
|
||||
</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: <hotelId></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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user