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:
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import client from '../api/client'
|
||||
|
||||
// Polls /api/orders/pending-prints every 8s and returns the count of pending print jobs.
|
||||
export default function useFailedPrintCount() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await client.get('/api/orders/pending-prints')
|
||||
if (!cancelled) setCount(res.data?.count ?? 0)
|
||||
} catch {
|
||||
// Network error — leave count as-is
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
const id = setInterval(refresh, 8000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import db from '../db/posdb'
|
||||
|
||||
// Polls offline_ops every 3 s and returns the count of unsynced ops.
|
||||
// Returns 0 when everything is flushed (hides the badge).
|
||||
export default function useOfflineQueueCount() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const ops = await db.offline_ops.toArray()
|
||||
if (!cancelled) setCount(ops.filter(o => !o.synced).length)
|
||||
} catch {
|
||||
// IDB unavailable — leave count as-is
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
const id = setInterval(refresh, 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
|
||||
export const PRODUCTS_KEY = ['products']
|
||||
export const CATEGORIES_KEY = ['categories']
|
||||
|
||||
async function fetchAndCacheProducts() {
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
@@ -14,7 +13,6 @@ async function fetchAndCacheProducts() {
|
||||
const products = prodRes.data
|
||||
const categories = catRes.data
|
||||
|
||||
// Write to IndexedDB in the background — don't await so UI isn't blocked
|
||||
db.products.bulkPut(products).catch(() => {})
|
||||
db.categories.bulkPut(categories).catch(() => {})
|
||||
|
||||
@@ -32,38 +30,43 @@ async function loadFromCache() {
|
||||
|
||||
export function useProductCache() {
|
||||
const queryClient = useQueryClient()
|
||||
const [idbData, setIdbData] = useState(null)
|
||||
const [idbLoaded, setIdbLoaded] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: PRODUCTS_KEY,
|
||||
queryFn: fetchAndCacheProducts,
|
||||
// Serve stale data instantly — products don't change every second
|
||||
staleTime: 5 * 60 * 1000, // 5 min before background re-fetch
|
||||
gcTime: 60 * 60 * 1000, // keep in memory for 1 hour
|
||||
placeholderData: undefined,
|
||||
})
|
||||
|
||||
// On mount, if the query has no data yet, seed it from IndexedDB immediately
|
||||
// so the UI renders without waiting for the network round-trip
|
||||
// Load IDB data immediately on mount — this is the offline fallback
|
||||
useEffect(() => {
|
||||
const cached = queryClient.getQueryData(PRODUCTS_KEY)
|
||||
if (cached) return
|
||||
|
||||
loadFromCache().then(idbData => {
|
||||
if (idbData) {
|
||||
// Set as placeholder — React Query will still fetch in background
|
||||
queryClient.setQueryData(PRODUCTS_KEY, idbData)
|
||||
loadFromCache().then(data => {
|
||||
setIdbData(data)
|
||||
setIdbLoaded(true)
|
||||
// Seed the query cache so React Query uses it as starting data
|
||||
if (data && !queryClient.getQueryData(PRODUCTS_KEY)) {
|
||||
queryClient.setQueryData(PRODUCTS_KEY, data)
|
||||
}
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: PRODUCTS_KEY,
|
||||
queryFn: fetchAndCacheProducts,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
// Don't retry on failure — axios interceptor already fired backend-offline
|
||||
// which switches connection state; we fall back to IDB data instead
|
||||
retry: false,
|
||||
// Only start the query after we've checked IDB, so IDB data is visible first
|
||||
enabled: idbLoaded,
|
||||
})
|
||||
|
||||
// Prefer live query data; fall back to IDB snapshot if query failed or is loading
|
||||
const data = query.data ?? idbData
|
||||
|
||||
return {
|
||||
products: query.data?.products ?? [],
|
||||
categories: query.data?.categories ?? [],
|
||||
isLoading: query.isLoading && !query.data,
|
||||
products: data?.products ?? [],
|
||||
categories: data?.categories ?? [],
|
||||
isLoading: !idbLoaded && query.isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
// Call this from SSEContext when products_changed arrives
|
||||
export function invalidateProductCache(queryClient) {
|
||||
queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user