- {!isFree &&
}
+
diff --git a/waiter_pwa/src/context/SSEContext.jsx b/waiter_pwa/src/context/SSEContext.jsx
index cae0e7f..d753fd7 100644
--- a/waiter_pwa/src/context/SSEContext.jsx
+++ b/waiter_pwa/src/context/SSEContext.jsx
@@ -1,10 +1,12 @@
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
import useAuthStore from '../store/authStore'
import useConnectionStore from '../store/connectionStore'
import { useSSE } from '../hooks/useSSE'
import db from '../db/posdb'
import client from '../api/client'
import { flushOfflinePayments } from '../services/offlinePayments'
+import { invalidateProductCache } from '../hooks/useProductCache'
const SSEContext = createContext(null)
@@ -17,6 +19,7 @@ const HEARTBEAT_INTERVAL = 30_000
export function SSEProvider({ children }) {
const { token } = useAuthStore()
const { setLost, setOnline } = useConnectionStore()
+ const queryClient = useQueryClient()
const sseAlive = useRef(false)
const heartbeatRef = useRef(null)
@@ -97,10 +100,14 @@ export function SSEProvider({ children }) {
await snapshotTables()
break
}
+ case 'products_changed': {
+ invalidateProductCache(queryClient)
+ break
+ }
default:
break
}
- }, [snapshotTables])
+ }, [snapshotTables, queryClient])
// ── SSE connection lifecycle ─────────────────────────────────────────────────
@@ -175,6 +182,32 @@ export function SSEProvider({ children }) {
return () => window.removeEventListener('backend-offline', onBackendOffline)
}, [])
+ // ── Wake-up handshake — fires when tab/app returns from background ────────────
+
+ useEffect(() => {
+ if (!token) return
+ async function onVisible() {
+ if (document.visibilityState !== 'visible') return
+ try {
+ await client.get('/api/system/health')
+ const currentStatus = useConnectionStore.getState().status
+ if (currentStatus === 'lost' || currentStatus === 'emergency' || currentStatus === 'reconnecting') {
+ setOnlineRef.current()
+ reconnect()
+ await fullRefresh()
+ } else if (!sseAlive.current) {
+ // SSE dropped silently while sleeping — re-establish quietly
+ reconnect()
+ await fullRefresh()
+ }
+ } catch {
+ if (!sseAlive.current) setLostRef.current()
+ }
+ }
+ document.addEventListener('visibilitychange', onVisible)
+ return () => document.removeEventListener('visibilitychange', onVisible)
+ }, [token, reconnect, fullRefresh])
+
// ── Initial snapshot on login ─────────────────────────────────────────────────
useEffect(() => {
diff --git a/waiter_pwa/src/db/posdb.js b/waiter_pwa/src/db/posdb.js
index 5ad26d9..cbd3405 100644
--- a/waiter_pwa/src/db/posdb.js
+++ b/waiter_pwa/src/db/posdb.js
@@ -7,9 +7,17 @@ import Dexie from 'dexie'
const db = new Dexie('pos_snapshot')
db.version(1).stores({
- tables: 'id, group_id, is_active', // TableOut snapshots
- orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots
- offline_payments: '++localId, uuid, synced', // queued emergency payments
+ tables: 'id, group_id, is_active',
+ orders: 'id, table_id, status',
+ offline_payments: '++localId, uuid, synced',
+})
+
+db.version(2).stores({
+ tables: 'id, group_id, is_active',
+ orders: 'id, table_id, status',
+ offline_payments: '++localId, uuid, synced',
+ products: 'id, category_id, is_available',
+ categories: 'id, parent_id',
})
export default db
diff --git a/waiter_pwa/src/hooks/useProductCache.js b/waiter_pwa/src/hooks/useProductCache.js
new file mode 100644
index 0000000..12d43f0
--- /dev/null
+++ b/waiter_pwa/src/hooks/useProductCache.js
@@ -0,0 +1,69 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useEffect } 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([
+ client.get('/api/products/'),
+ client.get('/api/products/categories'),
+ ])
+ 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(() => {})
+
+ return { products, categories }
+}
+
+async function loadFromCache() {
+ const [products, categories] = await Promise.all([
+ db.products.toArray(),
+ db.categories.toArray(),
+ ])
+ if (products.length === 0 && categories.length === 0) return null
+ return { products, categories }
+}
+
+export function useProductCache() {
+ const queryClient = useQueryClient()
+
+ 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
+ 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)
+ }
+ })
+ }, [queryClient])
+
+ return {
+ products: query.data?.products ?? [],
+ categories: query.data?.categories ?? [],
+ isLoading: query.isLoading && !query.data,
+ }
+}
+
+// Call this from SSEContext when products_changed arrives
+export function invalidateProductCache(queryClient) {
+ queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
+}
diff --git a/waiter_pwa/src/index.css b/waiter_pwa/src/index.css
index bbcc03e..c3298d3 100644
--- a/waiter_pwa/src/index.css
+++ b/waiter_pwa/src/index.css
@@ -667,7 +667,14 @@ html, body {
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
.user-menu-item--disabled:hover { background: transparent; }
.user-menu-item--danger { color: var(--danger); }
-.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
+.user-menu-item__icon {
+ font-size: 17px;
+ flex-shrink: 0;
+ width: 24px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
.user-menu-divider {
height: 1px;
background: var(--border);
diff --git a/waiter_pwa/src/pages/AddItemsPage.jsx b/waiter_pwa/src/pages/AddItemsPage.jsx
index d612332..abf18ae 100644
--- a/waiter_pwa/src/pages/AddItemsPage.jsx
+++ b/waiter_pwa/src/pages/AddItemsPage.jsx
@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import ProductPicker from '../components/ProductPicker'
import OrderDrawer from '../components/OrderDrawer'
import client from '../api/client'
+import { useProductCache } from '../hooks/useProductCache'
export default function AddItemsPage() {
const { tableId } = useParams()
@@ -10,8 +11,8 @@ export default function AddItemsPage() {
const isNewTable = searchParams.get('new') === '1'
const navigate = useNavigate()
- const [categories, setCategories] = useState([])
- const [products, setProducts] = useState([])
+ const { products, categories } = useProductCache()
+
const [cart, setCart] = useState([])
const [orderId, setOrderId] = useState(null)
const [sending, setSending] = useState(false)
@@ -24,16 +25,9 @@ export default function AddItemsPage() {
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
-
useEffect(() => {
async function load() {
- const [catRes, prodRes, statusRes] = await Promise.all([
- client.get('/api/products/categories'),
- client.get('/api/products/'),
- client.get(`/api/tables/${tableId}/status`),
- ])
- setCategories(catRes.data)
- setProducts(prodRes.data)
+ const statusRes = await client.get(`/api/tables/${tableId}/status`)
setOrderId(statusRes.data.active_order_id)
// Pre-populate cart from "order again" if present
diff --git a/waiter_pwa/src/pages/TableListPage.jsx b/waiter_pwa/src/pages/TableListPage.jsx
index c82c127..f68ab33 100644
--- a/waiter_pwa/src/pages/TableListPage.jsx
+++ b/waiter_pwa/src/pages/TableListPage.jsx
@@ -762,9 +762,10 @@ function ZoneTab({ label, color, active, onClick }) {
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 6,
- padding: '7px 12px', borderRadius: 20, border: 'none',
+ padding: '8px 16px', borderRadius: 20,
+ border: '2px solid transparent',
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
- fontWeight: 600, fontSize: 13,
+ fontWeight: 600, fontSize: 14,
background: active ? 'var(--accent)' : 'var(--bg3)',
color: active ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.12s, color 0.12s',
diff --git a/waiter_pwa/src/store/connectionStore.js b/waiter_pwa/src/store/connectionStore.js
index e9262ac..58ada35 100644
--- a/waiter_pwa/src/store/connectionStore.js
+++ b/waiter_pwa/src/store/connectionStore.js
@@ -4,26 +4,50 @@ import { create } from 'zustand'
* Tracks the live connection state and emergency mode flag.
*
* States:
- * 'online' — server reachable, SSE connected, normal operation
- * 'lost' — server unreachable, modal shown (Wait / Emergency)
- * 'emergency' — user chose emergency mode, working from IndexedDB snapshot
+ * 'online' — server reachable, SSE connected, normal operation
+ * 'reconnecting' — connection blip detected; 5-second grace before showing full modal
+ * 'lost' — grace period expired, modal shown (Wait / Emergency)
+ * 'emergency' — user chose emergency mode, working from IndexedDB snapshot
*/
+
+const GRACE_MS = 5_000
+
const useConnectionStore = create((set, get) => ({
- status: 'online', // 'online' | 'lost' | 'emergency'
- lostAt: null, // Date when connection was lost
+ status: 'online', // 'online' | 'reconnecting' | 'lost' | 'emergency'
+ lostAt: null,
+ _graceTimer: null,
setLost: () => {
- if (get().status === 'online') {
- set({ status: 'lost', lostAt: new Date() })
- }
+ const { status, _graceTimer } = get()
+ // Already lost or in emergency — no-op
+ if (status === 'lost' || status === 'emergency') return
+ // Already in grace period — don't restart the timer
+ if (status === 'reconnecting') return
+
+ // Start grace period
+ const timer = setTimeout(() => {
+ // Only escalate if we're still in reconnecting (not recovered in the meantime)
+ if (get().status === 'reconnecting') {
+ set({ status: 'lost', _graceTimer: null })
+ }
+ }, GRACE_MS)
+
+ set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: timer })
},
- setOnline: () => set({ status: 'online', lostAt: null }),
+ setOnline: () => {
+ const { _graceTimer } = get()
+ if (_graceTimer) clearTimeout(_graceTimer)
+ set({ status: 'online', lostAt: null, _graceTimer: null })
+ },
- enterEmergency: () => set({ status: 'emergency' }),
+ enterEmergency: () => {
+ const { _graceTimer } = get()
+ if (_graceTimer) clearTimeout(_graceTimer)
+ set({ status: 'emergency', _graceTimer: null })
+ },
- // Called when server comes back while in emergency mode — triggers sync then go online
- exitEmergency: () => set({ status: 'online', lostAt: null }),
+ exitEmergency: () => set({ status: 'online', lostAt: null, _graceTimer: null }),
isOnline: () => get().status === 'online',
isLost: () => get().status === 'lost',
diff --git a/waiter_pwa/vite.config.js b/waiter_pwa/vite.config.js
index b36273e..a6c9e97 100644
--- a/waiter_pwa/vite.config.js
+++ b/waiter_pwa/vite.config.js
@@ -13,8 +13,8 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
manifest: {
- name: 'TableServe',
- short_name: 'TableServe',
+ name: 'Xenia',
+ short_name: 'Xenia',
start_url: '/',
display: 'standalone',
background_color: '#0f172a',