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:
2026-07-21 17:02:25 +02:00
parent e9673551ce
commit 12fcb60b4c
18 changed files with 2314 additions and 112 deletions
+178
View File
@@ -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;
}