Files
polskalokalnie/frontend/src/notifications.tsx
T
2026-08-10 18:34:26 +02:00

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;
}