201 lines
6.8 KiB
TypeScript
201 lines
6.8 KiB
TypeScript
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<NotificationsContextValue | null>(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<ServerNotification[]>([]);
|
|
const sentDedupeKeys = useRef<Set<string>>(new Set());
|
|
|
|
const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0);
|
|
|
|
const refresh = useCallback(() => {
|
|
if (!getToken()) {
|
|
setNotifications([]);
|
|
return;
|
|
}
|
|
apiFetch<ServerNotification[]>('/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<ServerNotification | null>('/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 (
|
|
<NotificationsContext.Provider
|
|
value={{ notifications, unreadCount, markRead, markAllRead, notify, dismissByDedupePrefix, refresh }}
|
|
>
|
|
{children}
|
|
</NotificationsContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useNotifications(): NotificationsContextValue {
|
|
const context = useContext(NotificationsContext);
|
|
if (!context) {
|
|
throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider');
|
|
}
|
|
return context;
|
|
}
|