Require explicit persisted acknowledgement of notifications

This commit is contained in:
2026-09-06 12:52:14 +03:00
parent c7bb67b101
commit 458f638b81
7 changed files with 89 additions and 23 deletions
+6 -3
View File
@@ -12,6 +12,7 @@ export const Header = () => {
setIsAiSpecAnalyzerOpen,
userProfile,
notifications,
refreshNotifications,markNotificationsRead,notificationError,notificationBusy,
setActiveTab,
clearAllFilters,
isRefreshingLive,
@@ -144,7 +145,7 @@ export const Header = () => {
<div className="relative">
<button
aria-label="Bildirimler"
onClick={() => setShowNotifications(!showNotifications)}
onClick={() => {setShowNotifications(!showNotifications);if(!showNotifications)void refreshNotifications();}}
className="relative p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
@@ -157,13 +158,15 @@ export const Header = () => {
<div className="absolute right-0 mt-2 w-80 bg-white text-slate-800 rounded-xl shadow-xl border border-slate-200 py-2 z-50 animate-fade-in">
<div className="px-4 py-2 border-b border-slate-100 flex items-center justify-between">
<h4 className="font-bold text-sm">Bildirimler</h4>
<span className="text-xs text-blue-600 cursor-pointer hover:underline">Tümünü Okundu İşaretle</span>
<button disabled={notificationBusy || !notifications.some(n=>!n.read)} onClick={markNotificationsRead} className="text-xs text-blue-600 hover:underline disabled:opacity-50">Tümünü Okundu İşaretle</button>
</div>
{notificationError&&<p role="alert" className="px-3 py-2 text-xs text-red-700">{notificationError} <button className="underline" onClick={refreshNotifications}>Bildirimleri yeniden yükle</button></p>}
{notificationBusy&&<p role="status" className="px-3 text-xs">Okundu bilgisi kaydediliyor…</p>}
<div className="max-h-64 overflow-y-auto divide-y divide-slate-100">
{notifications.map(n => (
<div key={n.id} className="p-3 hover:bg-slate-50 transition-colors cursor-pointer text-xs">
<div className="font-semibold text-slate-900 flex justify-between">
<span>{n.title}</span>
<span>{!n.read&&<span className="text-blue-600 mr-1">Okunmadı ·</span>}{n.title}</span>
<span className="text-slate-400 font-normal text-[10px]">{n.time}</span>
</div>
<p className="text-slate-600 mt-1 leading-relaxed">{n.text}</p>
+38 -17
View File
@@ -1,3 +1,4 @@
import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications';
import { searchTender } from '../services/searchTender';
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
@@ -159,6 +160,7 @@ export const AppProvider = ({ children }) => {
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
@@ -564,11 +566,17 @@ export const AppProvider = ({ children }) => {
};
const [notifications, setNotifications] = useState([]);
const [notificationError,setNotificationError]=useState(null);
const [notificationBusy,setNotificationBusy]=useState(false);
const notificationVersion=useRef(0),notificationMutation=useRef(null),seenServerNotifications=useRef(new Set());
// Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı
// bildirimi (Notification API) da gösterir.
const notify = (title, text) => {
setNotifications((prev) => [{ id: 'n-' + Date.now(), title, text, time: 'az önce', read: false }, ...prev].slice(0, 30));
pushNotification(title,text);
};
const pushNotification=(title,text)=>{
if (isQuietNow() || !reminderSettings.pushEnabled) return;
try {
if ('Notification' in window && Notification.permission === 'granted') {
@@ -581,7 +589,31 @@ export const AppProvider = ({ children }) => {
if (Notification.permission === 'granted') return 'granted';
try { return await Notification.requestPermission(); } catch { return 'denied'; }
};
const markNotificationsRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
const refreshNotifications=async()=>{
const session=getToken(),version=++notificationVersion.current;
setNotificationError(null);
try {
const rows=await bildirimleriGetir();
if(getToken()!==session||version!==notificationVersion.current)return;
const checked=mergeServerNotifications([],rows);
const fresh=checked.filter(row=>!row.read&&!seenServerNotifications.current.has(row.id));
checked.forEach(row=>seenServerNotifications.current.add(row.id));
fresh.slice(0,5).forEach(row=>pushNotification(row.title,row.text));
setNotifications(current=>[...checked,...current.filter(row=>!row.serverId)].slice(0,80));
}catch(error){if(getToken()===session&&version===notificationVersion.current)setNotificationError(error.message);}
};
const markNotificationsRead=async()=>{
if(notificationMutation.current)return;
const operation={session:getToken()};notificationMutation.current=operation;
notificationVersion.current++;setNotificationBusy(true);setNotificationError(null);
try {
const ids=await acknowledgeNotifications(notifications,bildirimOkundu);
if(getToken()!==operation.session||notificationMutation.current!==operation)return;
notificationVersion.current++;
setNotifications(current=>current.map(row=>ids.has(row.id)?{...row,read:true}:row));
}catch(error){if(getToken()===operation.session&&notificationMutation.current===operation)setNotificationError(error.message);}
finally {if(notificationMutation.current===operation){notificationMutation.current=null;setNotificationBusy(false);}}
};
// Persist state to localstorage
// Havuz on binlerce kayda çıkabiliyor; tamamı localStorage kotasını (≈5 MB)
@@ -645,22 +677,10 @@ export const AppProvider = ({ children }) => {
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
useEffect(() => {
if (!veriSenkron) return;
let iptal = false;
const session = getToken();
const cek = async () => {
try {
const liste = await bildirimleriGetir();
const yeni = liste.filter((b) => !b.okundu);
if (iptal || getToken() !== session || !yeni.length) return;
for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin);
await bildirimOkundu(yeni.map((b) => b.id));
} catch { /* sessiz */ }
};
cek();
const z = setInterval(cek, 5 * 60 * 1000);
return () => { iptal = true; clearInterval(z); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [veriSenkron]);
void refreshNotifications();
const timer=setInterval(refreshNotifications,5*60*1000);
return()=>{notificationVersion.current++;clearInterval(timer);};
}, [veriSenkron,authUser?.email]);
// Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak
// belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar
@@ -807,6 +827,7 @@ export const AppProvider = ({ children }) => {
notify,
requestPushPermission,
markNotificationsRead,
refreshNotifications,notificationError,notificationBusy,
isQuietNow,
favoriteKurumlar,
toggleFavoriteKurum,
+11
View File
@@ -0,0 +1,11 @@
export function mergeServerNotifications(current,rows) {
if(!Array.isArray(rows)||rows.some(row=>typeof row?.id!=='string'||typeof row.baslik!=='string'||typeof row.metin!=='string'))throw new Error('Bildirim listesi geçersiz.');
const remote=rows.map(row=>({id:'server:'+row.id,serverId:row.id,title:row.baslik,text:row.metin,
time:new Date(row.zaman).toLocaleString('tr-TR'),read:!!row.okundu,ikn:row.ikn}));
return [...remote,...current.filter(row=>!row.serverId)].slice(0,80);
}
export async function acknowledgeNotifications(snapshot,request) {
const unread=snapshot.filter(row=>!row.read),ids=unread.filter(row=>row.serverId).map(row=>row.serverId);
if(ids.length){const result=await request(ids);if(result?.success!==true||result.okundu!==ids.length)throw new Error('Bazı bildirimler kaydedilemedi. Listeyi yenileyip tekrar deneyin.');}
return new Set(unread.map(row=>row.id));
}