255 lines
14 KiB
TypeScript
255 lines
14 KiB
TypeScript
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_admin'] },
|
||
{ 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_admin'] },
|
||
{ method: 'PATCH', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Обновить номер', tags: ['Rooms'], auth: true, roles: ['hotel_admin'] },
|
||
{ method: 'DELETE', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Удалить номер', tags: ['Rooms'], auth: true, roles: ['hotel_admin'] },
|
||
|
||
// 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_admin'] },
|
||
{ method: 'POST', path: '/hotels/:hotelId/channels/:channelId/sync', summary: 'Запустить синхронизацию', tags: ['Channels'], auth: true, roles: ['hotel_admin'] },
|
||
|
||
// 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>
|
||
)
|
||
}
|