Fix WS offline: nginx WebSocket headers + keepalive ping + drag-to-move fix

nginx: add /ws location with Upgrade/Connection headers and 1h timeout
useHotelSocket: ping every 30s to prevent idle timeout, read fresh token
  from localStorage on reconnect (handles 1h JWT expiry gracefully)
BookingCalendar: use elementFromPoint for reliable drag-to-move target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:24:37 +03:00
parent 3602ea7318
commit 26412a04dd
2 changed files with 55 additions and 9 deletions

View File

@@ -152,13 +152,24 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
}, [dragStart]) }, [dragStart])
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!pendingMoveBooking.current || movingBookingRef.current) return // Phase 1: detect drag start
if (pendingMoveBooking.current && !movingBookingRef.current) {
const dx = Math.abs(e.clientX - (moveStartPos.current?.x ?? 0)) const dx = Math.abs(e.clientX - (moveStartPos.current?.x ?? 0))
const dy = Math.abs(e.clientY - (moveStartPos.current?.y ?? 0)) const dy = Math.abs(e.clientY - (moveStartPos.current?.y ?? 0))
if (dx > 5 || dy > 5) { if (dx > 5 || dy > 5) {
setMovingBooking(pendingMoveBooking.current) setMovingBooking(pendingMoveBooking.current)
setMoveTargetRoomId(pendingMoveBooking.current.roomId) setMoveTargetRoomId(pendingMoveBooking.current.roomId)
} }
return
}
// Phase 2: track which room row the cursor is over
if (movingBookingRef.current) {
const el = document.elementFromPoint(e.clientX, e.clientY)
const rowEl = el?.closest('[data-room-id]') as HTMLElement | null
if (rowEl?.dataset.roomId) {
setMoveTargetRoomId(rowEl.dataset.roomId)
}
}
}, []) }, [])
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
@@ -429,12 +440,12 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
rows.push( rows.push(
<div <div
key={room.id} key={room.id}
data-room-id={room.id}
className={cn( className={cn(
'flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30', 'flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30',
movingBooking && moveTargetRoomId === room.id && 'bg-brand-50/60 dark:bg-brand-900/20', movingBooking && moveTargetRoomId === room.id && 'bg-brand-50/60 dark:bg-brand-900/20',
)} )}
style={{ height: rowHeight }} style={{ height: rowHeight }}
onMouseEnter={() => { if (movingBookingRef.current) setMoveTargetRoomId(room.id) }}
> >
{/* Room label */} {/* Room label */}
<div <div

View File

@@ -14,6 +14,15 @@ interface Options {
onMessage: (msg: WsMessage) => void onMessage: (msg: WsMessage) => void
} }
function getFreshToken(): string | null {
try {
const s = localStorage.getItem('hotelsync-session')
return s ? (JSON.parse(s) as { token: string }).token : null
} catch {
return null
}
}
export function useHotelSocket({ slug, token, onMessage }: Options) { export function useHotelSocket({ slug, token, onMessage }: Options) {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const cbRef = useRef(onMessage) const cbRef = useRef(onMessage)
@@ -30,14 +39,32 @@ export function useHotelSocket({ slug, token, onMessage }: Options) {
let destroyed = false let destroyed = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let attempts = 0 let attempts = 0
function startPing(ws: WebSocket) {
if (pingTimer) clearInterval(pingTimer)
// Send a ping every 30s to keep nginx / proxies from closing idle connection
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, 30_000)
}
function connect() { function connect() {
if (destroyed) return if (destroyed) return
// Always read fresh token so reconnects after 1h token expiry use the refreshed JWT
const currentToken = getFreshToken() ?? token
if (!currentToken) {
console.warn('[WS] no token available')
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 url = `${wsBase}/ws?hotel=${encodeURIComponent(slug)}&token=${encodeURIComponent(token!)}` const url = `${wsBase}/ws?hotel=${encodeURIComponent(slug)}&token=${encodeURIComponent(currentToken)}`
console.log('[WS] connecting to', url.replace(/token=[^&]+/, 'token=***')) console.log('[WS] connecting to', url.replace(/token=[^&]+/, 'token=***'))
const ws = new WebSocket(url) const ws = new WebSocket(url)
@@ -47,11 +74,17 @@ export function useHotelSocket({ slug, token, onMessage }: Options) {
if (destroyed) { ws.close(); return } if (destroyed) { ws.close(); return }
setConnected(true) setConnected(true)
attempts = 0 attempts = 0
startPing(ws)
console.log('[WS] connected ✓ hotel:', slug) console.log('[WS] connected ✓ hotel:', slug)
} }
ws.onmessage = (e) => { ws.onmessage = (e) => {
try { cbRef.current(JSON.parse(e.data as string) as WsMessage) } catch { /* ignore */ } try {
const msg = JSON.parse(e.data as string) as WsMessage
// ignore pong / ping from server
if ((msg as unknown as { type: string }).type === 'pong') return
cbRef.current(msg)
} catch { /* ignore */ }
} }
ws.onerror = (ev) => { ws.onerror = (ev) => {
@@ -61,6 +94,7 @@ export function useHotelSocket({ slug, token, onMessage }: Options) {
ws.onclose = (ev) => { ws.onclose = (ev) => {
wsRef.current = null wsRef.current = null
setConnected(false) setConnected(false)
if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
if (destroyed) return if (destroyed) return
const delay = Math.min(1000 * 2 ** attempts, 30_000) const delay = Math.min(1000 * 2 ** attempts, 30_000)
attempts++ attempts++
@@ -74,6 +108,7 @@ export function useHotelSocket({ slug, token, onMessage }: Options) {
return () => { return () => {
destroyed = true destroyed = true
if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectTimer) clearTimeout(reconnectTimer)
if (pingTimer) clearInterval(pingTimer)
wsRef.current?.close() wsRef.current?.close()
wsRef.current = null wsRef.current = null
setConnected(false) setConnected(false)