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
@@ -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
}
+28 -25
View File
@@ -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 })
}
+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 }
}