feat: bump client-services (accumulated feature work + deploy fixes)

Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
co-authored by Claude Sonnet 5
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* Persistent WebSocket hook — replaces useSSE.
*
* Protocol:
* 1. Connect to ws[s]://host/api/ws/connect?token=<jwt>
* 2. Send { cursor: <last_seq> } immediately (0 = first connect)
* 3. Receive replayed missed events, then { type: "ready" }
* 4. Live events: { seq, type, data }
* 5. Keepalive: server sends { type: "ping" } every 25s
*
* The hook stores `lastSeq` in IndexedDB so it survives page reloads.
* On every clean reconnect, missed events are replayed automatically.
*/
import { useCallback, useEffect, useRef } from 'react'
import db from '../db/posdb'
const INITIAL_RECONNECT_DELAY = 2_000
const MAX_RECONNECT_DELAY = 30_000
const CURSOR_KEY = 'ws_last_seq'
// ── Cursor persistence (IDB) ──────────────────────────────────────────────────
async function loadCursor() {
try {
const row = await db.kv?.get(CURSOR_KEY)
return row?.value ?? 0
} catch {
return 0
}
}
async function saveCursor(seq) {
try {
await db.kv?.put({ key: CURSOR_KEY, value: seq })
} catch {}
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useWebSocket({ token, onEvent, onConnect, onDisconnect, enabled = true }) {
const onEventRef = useRef(onEvent)
const onConnectRef = useRef(onConnect)
const onDisconnectRef = useRef(onDisconnect)
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
useEffect(() => { onConnectRef.current = onConnect }, [onConnect])
useEffect(() => { onDisconnectRef.current = onDisconnect }, [onDisconnect])
const wsRef = useRef(null)
const reconnectTimer = useRef(null)
const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY)
const unmounted = useRef(false)
const connectRef = useRef(null)
const lastSeq = useRef(0)
useEffect(() => {
if (!token || !enabled) return
unmounted.current = false
// Load persisted cursor before first connect
loadCursor().then(seq => {
lastSeq.current = seq
if (!unmounted.current) connectRef.current?.()
})
async function connect() {
if (unmounted.current) return
if (wsRef.current) {
wsRef.current.onclose = null // prevent double-reconnect
wsRef.current.close()
wsRef.current = null
}
// Build WS URL — same host, swap http(s) → ws(s)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/api/ws/connect?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
reconnectDelay.current = INITIAL_RECONNECT_DELAY
// Send cursor so server can replay missed events
ws.send(JSON.stringify({ cursor: lastSeq.current }))
}
ws.onmessage = (e) => {
let msg
try { msg = JSON.parse(e.data) } catch { return }
if (msg.type === 'ping') return // keepalive — no response needed
if (msg.type === 'ready') {
// Replay complete — we're now on the live stream
onConnectRef.current?.()
return
}
// Live or replayed event
if (msg.seq && msg.seq > lastSeq.current) {
lastSeq.current = msg.seq
saveCursor(msg.seq)
}
if (msg.type && msg.data !== undefined) {
onEventRef.current?.(msg.type, msg.data)
}
}
ws.onerror = () => {
// onerror always fires before onclose — let onclose handle reconnect
}
ws.onclose = () => {
wsRef.current = null
onDisconnectRef.current?.()
if (unmounted.current) return
// Schedule next attempt with current delay, then increase for the one after
reconnectTimer.current = setTimeout(() => {
reconnectDelay.current = Math.min(
reconnectDelay.current * 1.5,
MAX_RECONNECT_DELAY,
)
connect()
}, reconnectDelay.current)
}
}
connectRef.current = connect
// When the browser detects network restoration, immediately reset backoff and reconnect.
// This fires as soon as the OS reports connectivity — no need to wait for the
// backed-off timer or for the user to switch tabs.
function onNetworkOnline() {
clearTimeout(reconnectTimer.current)
reconnectDelay.current = INITIAL_RECONNECT_DELAY
connect()
}
window.addEventListener('online', onNetworkOnline)
return () => {
unmounted.current = true
clearTimeout(reconnectTimer.current)
window.removeEventListener('online', onNetworkOnline)
if (wsRef.current) {
wsRef.current.onclose = null
wsRef.current.close()
wsRef.current = null
}
}
}, [token, enabled])
const reconnect = useCallback(() => {
clearTimeout(reconnectTimer.current)
reconnectDelay.current = INITIAL_RECONNECT_DELAY
connectRef.current?.()
}, [])
return { reconnect }
}