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:
@@ -152,12 +152,23 @@ 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
|
||||||
const dx = Math.abs(e.clientX - (moveStartPos.current?.x ?? 0))
|
if (pendingMoveBooking.current && !movingBookingRef.current) {
|
||||||
const dy = Math.abs(e.clientY - (moveStartPos.current?.y ?? 0))
|
const dx = Math.abs(e.clientX - (moveStartPos.current?.x ?? 0))
|
||||||
if (dx > 5 || dy > 5) {
|
const dy = Math.abs(e.clientY - (moveStartPos.current?.y ?? 0))
|
||||||
setMovingBooking(pendingMoveBooking.current)
|
if (dx > 5 || dy > 5) {
|
||||||
setMoveTargetRoomId(pendingMoveBooking.current.roomId)
|
setMovingBooking(pendingMoveBooking.current)
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user