feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
170
waiter_pwa/src/context/NotificationContext.jsx
Normal file
170
waiter_pwa/src/context/NotificationContext.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
|
||||
const NotificationContext = createContext(null)
|
||||
|
||||
export function useNotifications() {
|
||||
return useContext(NotificationContext)
|
||||
}
|
||||
|
||||
// ─── Persistent banner (one message at a time, stacked) ───────────────────────
|
||||
|
||||
function NotificationBanner({ message, onAck }) {
|
||||
const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
background: '#1e1b4b', border: '1px solid #6366f1',
|
||||
borderRadius: 14, padding: '12px 14px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
animation: 'slideIn 0.25s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: 22, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{message.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{message.sender_name}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
|
||||
{message.body}
|
||||
</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
|
||||
Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAck(message.id)}
|
||||
style={{
|
||||
flexShrink: 0, height: 32, padding: '0 12px',
|
||||
borderRadius: 8, border: 'none',
|
||||
background: '#4f46e5', color: 'white',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>OK ✓</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { token, user } = useAuthStore()
|
||||
const [pendingMessages, setPendingMessages] = useState([])
|
||||
const [recentMessages, setRecentMessages] = useState([])
|
||||
|
||||
const fetchUnread = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/unread')
|
||||
setPendingMessages(res.data)
|
||||
} catch { /* offline or unauthenticated — swallow */ }
|
||||
}, [token, user?.id])
|
||||
|
||||
const fetchRecent = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/recent?limit=10')
|
||||
setRecentMessages(res.data)
|
||||
} catch { }
|
||||
}, [token, user?.id])
|
||||
|
||||
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
|
||||
useEffect(() => {
|
||||
if (!token || !user) return
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
const id = setInterval(fetchUnread, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [token, user?.id])
|
||||
|
||||
// SSE message_sent events → add to pending without polling
|
||||
useEffect(() => {
|
||||
function onSSEEvent(e) {
|
||||
const { type, data } = e.detail
|
||||
if (type !== 'message_sent') return
|
||||
if (!user) return
|
||||
|
||||
// Check if this message targets us (empty = broadcast)
|
||||
const targets = data.target_waiter_ids || []
|
||||
if (targets.length > 0 && !targets.includes(user.id)) return
|
||||
|
||||
const msg = {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
sender_name: data.sender_name,
|
||||
body: data.body,
|
||||
table_ids: data.table_ids,
|
||||
created_at: data.created_at,
|
||||
acked_by: [],
|
||||
}
|
||||
|
||||
setPendingMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev]
|
||||
})
|
||||
setRecentMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev].slice(0, 10)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('sse-event', onSSEEvent)
|
||||
return () => window.removeEventListener('sse-event', onSSEEvent)
|
||||
}, [user?.id])
|
||||
|
||||
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
|
||||
useEffect(() => {
|
||||
function onSSEConnect() {
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
}
|
||||
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
|
||||
// through the backend-coming-back-online signal that SSEProvider dispatches
|
||||
window.addEventListener('sse-reconnected', onSSEConnect)
|
||||
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
|
||||
}, [fetchUnread, fetchRecent])
|
||||
|
||||
async function ackMessage(messageId) {
|
||||
try {
|
||||
await client.post(`/api/messages/${messageId}/ack`)
|
||||
setPendingMessages(prev => prev.filter(m => m.id !== messageId))
|
||||
fetchRecent()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const unreadCount = pendingMessages.length
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
|
||||
{children}
|
||||
|
||||
{/* Floating banner stack (max 3 visible) */}
|
||||
{pendingMessages.length > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
|
||||
padding: '0 12px',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
|
||||
{pendingMessages.slice(0, 3).map(msg => (
|
||||
<div key={msg.id} style={{ pointerEvents: 'all' }}>
|
||||
<NotificationBanner message={msg} onAck={ackMessage} />
|
||||
</div>
|
||||
))}
|
||||
{pendingMessages.length > 3 && (
|
||||
<div style={{
|
||||
textAlign: 'center', fontSize: 12, color: '#94a3b8',
|
||||
pointerEvents: 'all',
|
||||
}}>
|
||||
+{pendingMessages.length - 3} ακόμα μηνύματα
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
189
waiter_pwa/src/context/SSEContext.jsx
Normal file
189
waiter_pwa/src/context/SSEContext.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
|
||||
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'
|
||||
|
||||
const SSEContext = createContext(null)
|
||||
|
||||
export function useSSEContext() {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000
|
||||
|
||||
export function SSEProvider({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
const { setLost, setOnline } = useConnectionStore()
|
||||
const sseAlive = useRef(false)
|
||||
const heartbeatRef = useRef(null)
|
||||
|
||||
// Keep setLost/setOnline in refs so heartbeat/event closures are never stale
|
||||
const setLostRef = useRef(setLost)
|
||||
const setOnlineRef = useRef(setOnline)
|
||||
useEffect(() => { setLostRef.current = setLost }, [setLost])
|
||||
useEffect(() => { setOnlineRef.current = setOnline }, [setOnline])
|
||||
|
||||
// ── Snapshot helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const snapshotTables = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/tables/')
|
||||
await db.tables.bulkPut(res.data)
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [])
|
||||
|
||||
const snapshotOrders = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/orders/active')
|
||||
const slimOrders = res.data
|
||||
// Fetch full order details (with items) so emergency mode has them
|
||||
const fullOrders = await Promise.all(
|
||||
slimOrders.map(o =>
|
||||
client.get(`/api/orders/${o.id}`)
|
||||
.then(r => ({
|
||||
...r.data,
|
||||
waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [],
|
||||
}))
|
||||
.catch(() => o)
|
||||
)
|
||||
)
|
||||
await db.orders.bulkPut(fullOrders)
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [])
|
||||
|
||||
const fullRefresh = useCallback(async () => {
|
||||
await Promise.all([snapshotTables(), snapshotOrders()])
|
||||
}, [snapshotTables, snapshotOrders])
|
||||
|
||||
// ── SSE event handler ────────────────────────────────────────────────────────
|
||||
|
||||
const handleEvent = useCallback(async (type, data) => {
|
||||
// Dispatch for any UI component listening to window events
|
||||
window.dispatchEvent(new CustomEvent('sse-event', { detail: { type, data } }))
|
||||
|
||||
// Incrementally update IndexedDB snapshot
|
||||
switch (type) {
|
||||
case 'order_updated':
|
||||
case 'order_paid': {
|
||||
// Try to fetch the full order to keep items in the snapshot
|
||||
try {
|
||||
const full = await client.get(`/api/orders/${data.order_id}`)
|
||||
const o = full.data
|
||||
await db.orders.put({
|
||||
...o,
|
||||
waiter_ids: o.waiters?.map(w => w.waiter_id) ?? [],
|
||||
})
|
||||
} catch {
|
||||
// Fallback: update only the slim fields we know
|
||||
const existing = await db.orders.get(data.order_id)
|
||||
await db.orders.put({
|
||||
...(existing || {}),
|
||||
id: data.order_id,
|
||||
table_id: data.table_id,
|
||||
status: data.status,
|
||||
waiter_ids: existing?.waiter_ids || [],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'order_closed': {
|
||||
await db.orders.delete(data.order_id)
|
||||
break
|
||||
}
|
||||
case 'table_list_changed': {
|
||||
await snapshotTables()
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [snapshotTables])
|
||||
|
||||
// ── SSE connection lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
sseAlive.current = true
|
||||
const wasEmergency = useConnectionStore.getState().status === 'emergency'
|
||||
setOnlineRef.current()
|
||||
window.dispatchEvent(new Event('sse-reconnected'))
|
||||
if (wasEmergency) {
|
||||
const result = await flushOfflinePayments()
|
||||
if (result.duplicates > 0 || result.failed > 0) {
|
||||
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
|
||||
}
|
||||
}
|
||||
await fullRefresh()
|
||||
}, [fullRefresh])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
sseAlive.current = false
|
||||
// Don't immediately setLost — heartbeat is the authoritative check
|
||||
}, [])
|
||||
|
||||
const { reconnect } = useSSE({
|
||||
token,
|
||||
enabled: !!token,
|
||||
onEvent: handleEvent,
|
||||
onConnect: handleConnect,
|
||||
onDisconnect: handleDisconnect,
|
||||
})
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
async function beat() {
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
const currentStatus = useConnectionStore.getState().status
|
||||
if (currentStatus === 'lost' || currentStatus === 'emergency') {
|
||||
if (currentStatus === 'emergency') {
|
||||
const result = await flushOfflinePayments()
|
||||
if (result.duplicates > 0 || result.failed > 0) {
|
||||
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
|
||||
}
|
||||
}
|
||||
setOnlineRef.current()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
}
|
||||
} catch {
|
||||
if (!sseAlive.current) {
|
||||
setLostRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
heartbeatRef.current = setInterval(beat, HEARTBEAT_INTERVAL)
|
||||
return () => clearInterval(heartbeatRef.current)
|
||||
// reconnect and fullRefresh are stable (useCallback with no changing deps)
|
||||
}, [token, reconnect, fullRefresh])
|
||||
|
||||
// ── React to failed API requests (immediate detection) ───────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
function onBackendOffline() {
|
||||
if (!sseAlive.current) {
|
||||
setLostRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener('backend-offline', onBackendOffline)
|
||||
return () => window.removeEventListener('backend-offline', onBackendOffline)
|
||||
}, [])
|
||||
|
||||
// ── Initial snapshot on login ─────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fullRefresh()
|
||||
}, [token, fullRefresh])
|
||||
|
||||
return (
|
||||
<SSEContext.Provider value={{ reconnect, fullRefresh }}>
|
||||
{children}
|
||||
</SSEContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user