Files
xenia-pos-local/waiter_pwa/src/context/NotificationContext.jsx
bonamin 8ba8c95ecd 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>
2026-05-20 14:04:38 +03:00

171 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}