import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { apiFetch, getToken, useAuth } from './auth'; export type NotificationCategoryKey = 'listing' | 'messages' | 'system'; // Powiadomienie w formie z backendu. export type ServerNotification = { id: number; category: NotificationCategoryKey; icon: string; title: string; body: string | null; link: string | null; dedupeKey: string | null; read: boolean; createdAt: string; }; // Zdarzenia liczone po stronie klienta (alerty cen, spotkania, ulubione, telefon, dopasowania). export type NotifyInput = { category: NotificationCategoryKey; icon?: string; title: string; body?: string; link?: string; dedupeKey?: string; }; // Kształt do renderu (zgodny z istniejącym UI: title/description/timeLabel/dayLabel/category/icon/unread/action). export type DisplayNotification = ServerNotification & { description: string; timeLabel: string; dayLabel: 'Dzisiaj' | 'Wczoraj'; unread: boolean; action?: 'arrow'; }; type NotificationsContextValue = { notifications: ServerNotification[]; unreadCount: number; markRead: (id: number) => void; markAllRead: () => void; notify: (input: NotifyInput) => void; // Kasuje powiadomienia o kluczu zaczynajacym sie od prefiksu - uzywane przy usuwaniu // alertu cenowego, zeby nie zostal po nim slad. dismissByDedupePrefix: (prefix: string) => void; refresh: () => void; }; const NotificationsContext = createContext(null); function pad(value: number): string { return value < 10 ? `0${value}` : String(value); } // Mapowanie listy z backendu na kształt oczekiwany przez istniejący widok (bez zmiany designu). export function toDisplayNotifications(list: ServerNotification[]): DisplayNotification[] { const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; return list.map((item) => { const created = new Date(item.createdAt); const createdMs = created.getTime(); const time = `${pad(created.getHours())}:${pad(created.getMinutes())}`; const isToday = createdMs >= startOfToday; const isYesterday = createdMs >= startOfYesterday && createdMs < startOfToday; let timeLabel: string; if (isToday) { timeLabel = time; } else if (isYesterday) { timeLabel = `Wczoraj, ${time}`; } else { timeLabel = `${pad(created.getDate())}.${pad(created.getMonth() + 1)}, ${time}`; } const unread = !item.read; return { ...item, description: item.body ?? '', timeLabel, // Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" - starsze trafiają do "Wczoraj". dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj', unread, action: !unread && item.link ? 'arrow' : undefined, }; }); } export function NotificationsProvider({ children }: { children: ReactNode }) { const { user } = useAuth(); const [notifications, setNotifications] = useState([]); const sentDedupeKeys = useRef>(new Set()); const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0); const refresh = useCallback(() => { if (!getToken()) { setNotifications([]); return; } apiFetch('/notifications') .then((data) => setNotifications(Array.isArray(data) ? data : [])) .catch(() => { /* cicho - brak sieci/tokenu */ }); }, []); // Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. Token w query param (EventSource nie ustawia nagłówków). useEffect(() => { const token = getToken(); if (!user || !token) { setNotifications([]); sentDedupeKeys.current = new Set(); return; } refresh(); const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`); source.addEventListener('notification', (event) => { try { const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification; setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current])); } catch { /* ignoruj nieparsowalne zdarzenie */ } }); // onerror: EventSource sam ponawia połączenie. // Zapasowy polling na wypadek zerwanego SSE. const pollId = window.setInterval(refresh, 60000); return () => { source.close(); window.clearInterval(pollId); }; }, [user, refresh]); const markRead = useCallback((id: number) => { setNotifications((current) => current.map((item) => (item.id === id ? { ...item, read: true } : item))); apiFetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {}); }, []); const markAllRead = useCallback(() => { setNotifications((current) => current.map((item) => ({ ...item, read: true }))); apiFetch('/notifications/read-all', { method: 'POST' }).catch(() => {}); }, []); const notify = useCallback((input: NotifyInput) => { if (!getToken() || !input.title) { return; } if (input.dedupeKey) { if (sentDedupeKeys.current.has(input.dedupeKey)) { return; } sentDedupeKeys.current.add(input.dedupeKey); } apiFetch('/notifications', { method: 'POST', body: JSON.stringify(input) }) .then((created) => { if (created && created.id) { setNotifications((current) => (current.some((item) => item.id === created.id) ? current : [created, ...current])); } }) .catch(() => {}); }, []); const dismissByDedupePrefix = useCallback((prefix: string) => { if (!getToken() || !prefix) { return; } // Znikaja od razu z listy, reszte sprzata backend. setNotifications((current) => current.filter((item) => !(item.dedupeKey ?? '').startsWith(prefix))); // Zdejmujemy tez blokade deduplikacji, inaczej po ponownym dodaniu alertu ta sama // zmiana ceny nie wygenerowalaby juz powiadomienia w tej sesji. sentDedupeKeys.current.forEach((key) => { if (key.startsWith(prefix)) { sentDedupeKeys.current.delete(key); } }); apiFetch(`/notifications?dedupePrefix=${encodeURIComponent(prefix)}`, { method: 'DELETE' }).catch(() => {}); }, []); return ( {children} ); } export function useNotifications(): NotificationsContextValue { const context = useContext(NotificationsContext); if (!context) { throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider'); } return context; }