Files
hotelsync/src/pages/ApiDocsPage.tsx

255 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &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>
)
}