Kalkulator zdolności, integracja Moich ofert i system powiadomień
- Kalkulator zdolności kredytowej: nowy widok z live-obliczeniami (rata annuitetowa/malejąca, DTI/DSTI, rekomendacje, donut), wpięty w poradnik, panel i stronę główną; pasek boczny konta dla zalogowanych. - Moje oferty: prawdziwe ogłoszenia (/listings/mine) scalone z listą, realne akcje Wstrzymaj/Wznów (PATCH /status, nowy status PAUSED), Usuń (DELETE) z kontrolą właściciela; realne wyświetlenia (viewsCount) i zapisane z ulubionych. Fix CHECK constraintu enuma w SchemaFixer. - System powiadomień: encja Notification + serwis + kontroler + SSE realtime (token w query param w JwtAuthFilter). Podpięte źródła serwerowe (wiadomości, zmiana statusu ogłoszenia, zdarzenia konta) i klienckie (ulubione, spadek ceny, spotkania, weryfikacja telefonu). Centralny NotificationsProvider; dzwonek i strona bez zmian designu, klikalne z deep-linkami i oznaczaniem jako przeczytane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
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;
|
||||
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(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, notify, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user