171 lines
5.8 KiB
JavaScript
171 lines
5.8 KiB
JavaScript
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>
|
||
)
|
||
}
|