Add WS reconnect logic + connection indicator in calendar
- useHotelSocket: reconnect with exponential backoff (up to 30s), return connected state, log errors to console - BookingCalendar: show green/gray dot + Online/Offline label in toolbar - CalendarPage: pass wsConnected prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ interface BookingCalendarProps {
|
|||||||
locks?: Map<string, BookingLock>
|
locks?: Map<string, BookingLock>
|
||||||
onDraftStart?: (roomId: string, checkIn: string, checkOut: string) => void
|
onDraftStart?: (roomId: string, checkIn: string, checkOut: string) => void
|
||||||
onDraftCancel?: (roomId: string) => void
|
onDraftCancel?: (roomId: string) => void
|
||||||
|
wsConnected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_ORDER: Record<string, number> = {
|
const CATEGORY_ORDER: Record<string, number> = {
|
||||||
@@ -54,7 +55,7 @@ function getRoomTypeColor(type: string): string {
|
|||||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel }: BookingCalendarProps) {
|
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) {
|
||||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||||
|
|
||||||
@@ -266,6 +267,20 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WebSocket status indicator */}
|
||||||
|
{wsConnected !== undefined && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs text-slate-400 dark:text-slate-500"
|
||||||
|
title={wsConnected ? 'Синхронизация активна' : 'Нет соединения — переподключение...'}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'w-1.5 h-1.5 rounded-full',
|
||||||
|
wsConnected ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600 animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="hidden sm:inline">{wsConnected ? 'Онлайн' : 'Офлайн'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompact(v => { const next = !v; localStorage.setItem('calendarCompact', String(next)); return next })}
|
onClick={() => setCompact(v => { const next = !v; localStorage.setItem('calendarCompact', String(next)); return next })}
|
||||||
title={compact ? 'Обычный вид' : 'Компактный вид'}
|
title={compact ? 'Обычный вид' : 'Компактный вид'}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { Booking } from '../types'
|
import type { Booking } from '../types'
|
||||||
|
|
||||||
export type WsMessage =
|
export type WsMessage =
|
||||||
@@ -19,26 +19,70 @@ export function useHotelSocket({ slug, token, onMessage }: Options) {
|
|||||||
const cbRef = useRef(onMessage)
|
const cbRef = useRef(onMessage)
|
||||||
cbRef.current = onMessage
|
cbRef.current = onMessage
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug || !token) return
|
if (!slug || !token) return
|
||||||
|
|
||||||
|
let destroyed = false
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (destroyed) return
|
||||||
|
|
||||||
const base = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
|
const base = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
|
||||||
const wsBase = base.replace(/^https/, 'wss').replace(/^http(?!s)/, 'ws')
|
const wsBase = base.replace(/^https/, 'wss').replace(/^http(?!s)/, 'ws')
|
||||||
const ws = new WebSocket(`${wsBase}/ws?hotel=${encodeURIComponent(slug)}&token=${encodeURIComponent(token)}`)
|
const url = `${wsBase}/ws?hotel=${encodeURIComponent(slug)}&token=${encodeURIComponent(token!)}`
|
||||||
|
|
||||||
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (destroyed) { ws.close(); return }
|
||||||
|
setConnected(true)
|
||||||
|
attempts = 0
|
||||||
|
console.debug('[WS] connected', slug)
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
try { cbRef.current(JSON.parse(e.data as string) as WsMessage) } catch { /* ignore */ }
|
try { cbRef.current(JSON.parse(e.data as string) as WsMessage) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
ws.onerror = () => { /* silent */ }
|
|
||||||
|
|
||||||
return () => { ws.close(); wsRef.current = null }
|
ws.onerror = (ev) => {
|
||||||
|
console.warn('[WS] error', ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
wsRef.current = null
|
||||||
|
setConnected(false)
|
||||||
|
if (destroyed) return
|
||||||
|
const delay = Math.min(1000 * 2 ** attempts, 30_000)
|
||||||
|
attempts++
|
||||||
|
console.debug(`[WS] closed (${ev.code}), reconnecting in ${delay}ms (attempt ${attempts})`)
|
||||||
|
reconnectTimer = setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyed = true
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
wsRef.current?.close()
|
||||||
|
wsRef.current = null
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
}, [slug, token])
|
}, [slug, token])
|
||||||
|
|
||||||
const send = useCallback((msg: WsMessage) => {
|
const send = useCallback((msg: WsMessage) => {
|
||||||
const ws = wsRef.current
|
const ws = wsRef.current
|
||||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg))
|
||||||
|
} else {
|
||||||
|
console.warn('[WS] send skipped — not connected', msg)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { send }
|
return { send, connected }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { send } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
const { send, connected } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||||||
|
|
||||||
const handleCreate = async (data: Partial<Booking>) => {
|
const handleCreate = async (data: Partial<Booking>) => {
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +134,7 @@ export function CalendarPage() {
|
|||||||
locks={locks}
|
locks={locks}
|
||||||
onDraftStart={handleDraftStart}
|
onDraftStart={handleDraftStart}
|
||||||
onDraftCancel={handleDraftCancel}
|
onDraftCancel={handleDraftCancel}
|
||||||
|
wsConnected={connected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user