feat: otomatik e-posta bildirimi
Kullanıcı kriter kaydediyor; sunucu periyodik olarak eşleşen YENİ ihaleleri bulup özet e-posta gönderiyor. - server/notify-store.mjs: abonelik deposu (SQLite, kalıcı volume'de) kriterler: il · sektör · alım türü · anahtar kelime · favori kurumlar - server/notifier.mjs: eşleştirme + gönderim motoru · aynı ihale iki kez bildirilmez (sonGorulenler takibi) · ilk abonelikte geçmiş bildirilmez, yalnızca temel alınır (spam önleme) · sonuçlanmış/arşiv kayıtları bildirime girmez - /api/notify/subscriptions (GET/POST/DELETE) + /api/notify/run (admin, anlık test) - scheduler: NOTIFY_INTERVAL_SAAT (varsayılan 6) ile periyodik tur - NotifySubscriptions paneli: kriter ekle/sil, 'ekrandaki filtreleri kullan' - tendersPayload ortak yardımcısına çıkarıldı (API ve bildirim aynı veriyi kullanır)
This commit is contained in:
+67
-32
@@ -25,6 +25,8 @@ import { readHistory, historyStats } from './history-store.mjs';
|
||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||
import { searchKik, kararMetni } from './kik.mjs';
|
||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
|
||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||
@@ -395,6 +397,33 @@ function readBody(req, limit = 100 * 1024) {
|
||||
});
|
||||
}
|
||||
|
||||
// /api/tenders gövdesini üretir; bildirim işi de aynı veriyi kullanır.
|
||||
async function tendersPayload(force = false) {
|
||||
const c = await getTenders(force);
|
||||
let dt = { tenders: [], totalCount: 0, available: false };
|
||||
try { dt = readDtTenders(DT_LIMIT); }
|
||||
catch (e) { console.warn('[ihaleist-api] DT okuma hatası:', e.message); }
|
||||
let hist = { tenders: [], totalCount: 0, available: false };
|
||||
try { hist = readHistory(HISTORY_LIMIT); }
|
||||
catch (e) { console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message); }
|
||||
|
||||
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
|
||||
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
|
||||
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
source: sources.join(' + '),
|
||||
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
|
||||
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
|
||||
ilanCount: c.totalCount,
|
||||
todayCount: c.todayCount,
|
||||
dtCount: dt.totalCount,
|
||||
historyCount: hist.totalCount,
|
||||
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
try {
|
||||
@@ -561,6 +590,40 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Otomatik bildirim abonelikleri ---
|
||||
if (url.pathname === '/api/notify/subscriptions') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
if (!notifyAvailable()) return json(res, 503, { success: false, error: 'Bildirim deposu kullanılamıyor' });
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const abone = subscribe(user.email, body.kriter || {}, user.name);
|
||||
return json(res, 200, { success: true, abonelikler: abone });
|
||||
} catch (e) {
|
||||
return json(res, 400, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
const ok = removeSub(id, user.email);
|
||||
return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) });
|
||||
}
|
||||
return json(res, 200, { success: true, abonelikler: listSubs(user.email) });
|
||||
}
|
||||
// Aboneliklerin hemen işlenmesi (admin/test)
|
||||
if (url.pathname === '/api/notify/run' && req.method === 'POST') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici yetkisi gerekli' });
|
||||
try {
|
||||
const sonuc = await bildirimleriIsle(() => tendersPayload());
|
||||
return json(res, 200, { success: true, ...sonuc });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- E-posta bildirimi (Resend) ---
|
||||
if (url.pathname === '/api/notify/email/config') {
|
||||
return json(res, 200, { configured: emailConfigured() });
|
||||
@@ -585,37 +648,8 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tenders') {
|
||||
const force = url.searchParams.get('refresh') === '1';
|
||||
const c = await getTenders(force);
|
||||
// Doğrudan temin (EKAP worker) verisini varsa birleştir
|
||||
let dt = { tenders: [], totalCount: 0, available: false };
|
||||
try {
|
||||
dt = readDtTenders(DT_LIMIT);
|
||||
} catch (e) {
|
||||
console.warn('[ihaleist-api] DT okuma hatası:', e.message);
|
||||
}
|
||||
// Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı)
|
||||
let hist = { tenders: [], totalCount: 0, available: false };
|
||||
try {
|
||||
hist = readHistory(HISTORY_LIMIT);
|
||||
} catch (e) {
|
||||
console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message);
|
||||
}
|
||||
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
|
||||
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
|
||||
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
|
||||
return json(res, 200, {
|
||||
success: true,
|
||||
source: sources.join(' + '),
|
||||
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
|
||||
// Havuz = canlı ilan + doğrudan temin + sonuç arşivi (yüklü sayısıyla tutarlı olsun)
|
||||
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
|
||||
ilanCount: c.totalCount,
|
||||
todayCount: c.todayCount,
|
||||
dtCount: dt.totalCount,
|
||||
historyCount: hist.totalCount,
|
||||
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
|
||||
});
|
||||
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/dt') {
|
||||
@@ -666,5 +700,6 @@ const server = http.createServer(async (req, res) => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
|
||||
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
|
||||
startScheduler();
|
||||
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
|
||||
startScheduler({ tenderGetir: () => tendersPayload() });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur
|
||||
* ve özet e-posta gönderir.
|
||||
*
|
||||
* Env:
|
||||
* NOTIFY_ENABLED=0 → kapat
|
||||
* NOTIFY_INTERVAL_SAAT=6 → kontrol sıklığı
|
||||
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale
|
||||
*/
|
||||
|
||||
import { list, markSent, available } from './notify-store.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
|
||||
|
||||
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
|
||||
|
||||
/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */
|
||||
export function eslesiyorMu(t, a) {
|
||||
if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false;
|
||||
if (a.sektor && norm(t.sektor) !== norm(a.sektor)) return false;
|
||||
if (a.alimTuru && !norm(t.alimTuru).includes(norm(a.alimTuru))) return false;
|
||||
if (a.anahtar) {
|
||||
const q = norm(a.anahtar);
|
||||
const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
|
||||
if (!alan.includes(q)) return false;
|
||||
}
|
||||
if (a.kurumlar?.length) {
|
||||
const kurum = norm(t.kurum);
|
||||
if (!a.kurumlar.some((k) => norm(k) === kurum)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */
|
||||
function bildirilebilir(t) {
|
||||
if (t.durum === 'Sonuçlandı') return false;
|
||||
if (String(t.id || '').startsWith('hist-')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tüm aktif abonelikleri işler.
|
||||
* @param {Function} tenderGetir () => Promise<{tenders: []}>
|
||||
*/
|
||||
export async function bildirimleriIsle(tenderGetir) {
|
||||
if (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' };
|
||||
if (!available()) return { atlandi: 'depo yok' };
|
||||
if (!emailConfigured()) return { atlandi: 'e-posta yapılandırılmamış' };
|
||||
|
||||
const abonelikler = list();
|
||||
if (!abonelikler.length) return { abonelik: 0 };
|
||||
|
||||
const veri = await tenderGetir();
|
||||
const tumu = (veri.tenders || []).filter(bildirilebilir);
|
||||
const maxAdet = Number(process.env.NOTIFY_MAX || 25);
|
||||
|
||||
let gonderilen = 0;
|
||||
let hata = 0;
|
||||
|
||||
for (const a of abonelikler) {
|
||||
try {
|
||||
const gorulen = new Set(a.sonGorulenler || []);
|
||||
const eslesen = tumu.filter((t) => eslesiyorMu(t, a));
|
||||
const yeniler = eslesen.filter((t) => !gorulen.has(t.id));
|
||||
|
||||
// İlk kurulumda geçmişi spam'lemeyelim: sadece işaretle, gönderme.
|
||||
if (!a.sonGonderim) {
|
||||
markSent(a.id, eslesen.map((t) => t.id));
|
||||
continue;
|
||||
}
|
||||
if (!yeniler.length) continue;
|
||||
|
||||
const kriterMetni = [
|
||||
a.sehir && `İl: ${a.sehir}`,
|
||||
a.sektor && `Sektör: ${a.sektor}`,
|
||||
a.alimTuru && `Tür: ${a.alimTuru}`,
|
||||
a.anahtar && `Anahtar: ${a.anahtar}`,
|
||||
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
|
||||
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
|
||||
|
||||
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: yeniler.slice(0, maxAdet) })
|
||||
+ `<p style="font-family:Arial,sans-serif;font-size:11px;color:#8b93a4;max-width:640px;margin:12px auto 0">
|
||||
Kriteriniz: ${kriterMetni} — toplam ${yeniler.length} yeni ihale.
|
||||
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
|
||||
</p>`;
|
||||
|
||||
await sendEmail({
|
||||
to: a.email,
|
||||
subject: `İhaleist — ${yeniler.length} yeni ihale (${kriterMetni})`,
|
||||
html,
|
||||
});
|
||||
markSent(a.id, eslesen.map((t) => t.id));
|
||||
gonderilen++;
|
||||
console.log(`[notifier] ${a.email} → ${yeniler.length} yeni ihale gönderildi (${kriterMetni})`);
|
||||
} catch (e) {
|
||||
hata++;
|
||||
console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { abonelik: abonelikler.length, gonderilen, hata };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Otomatik e-posta bildirimi abonelikleri (SQLite).
|
||||
* Kullanıcı kendi filtre kriterlerini kaydeder; zamanlayıcı yeni eşleşen
|
||||
* ihaleleri bulup özet e-posta gönderir.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
let DatabaseSync = null;
|
||||
try {
|
||||
({ DatabaseSync } = await import('node:sqlite'));
|
||||
} catch {
|
||||
console.warn('[notify-store] node:sqlite yok — otomatik bildirim devre dışı');
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'notify.sqlite');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS abonelikler (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
ad TEXT,
|
||||
sehir TEXT,
|
||||
sektor TEXT,
|
||||
alim_turu TEXT,
|
||||
anahtar TEXT,
|
||||
kurumlar TEXT, -- JSON: favori kurum listesi
|
||||
aktif INTEGER NOT NULL DEFAULT 1,
|
||||
sonGonderim TEXT,
|
||||
sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri
|
||||
olusturma TEXT,
|
||||
UNIQUE(email, sehir, sektor, alim_turu, anahtar)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email);
|
||||
`;
|
||||
|
||||
export function available() {
|
||||
return Boolean(DatabaseSync);
|
||||
}
|
||||
|
||||
function open({ create = true } = {}) {
|
||||
if (!DatabaseSync) return null;
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
if (!create) return null;
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
const bosaNull = (v) => (v == null || v === '' ? '' : String(v).trim());
|
||||
|
||||
export function subscribe(email, kriter = {}, ad = null) {
|
||||
const db = open();
|
||||
if (!db) throw new Error('Bildirim deposu kullanılamıyor');
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, aktif, olusturma)
|
||||
VALUES (?,?,?,?,?,?,?,1,?)
|
||||
ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET
|
||||
aktif=1, kurumlar=excluded.kurumlar, ad=excluded.ad
|
||||
`).run(
|
||||
email,
|
||||
ad,
|
||||
bosaNull(kriter.sehir),
|
||||
bosaNull(kriter.sektor),
|
||||
bosaNull(kriter.alimTuru),
|
||||
bosaNull(kriter.anahtar),
|
||||
JSON.stringify(kriter.kurumlar || []),
|
||||
new Date().toISOString()
|
||||
);
|
||||
return list(email);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function list(email) {
|
||||
const db = open({ create: false });
|
||||
if (!db) return [];
|
||||
try {
|
||||
const rows = email
|
||||
? db.prepare('SELECT * FROM abonelikler WHERE email=? ORDER BY id DESC').all(email)
|
||||
: db.prepare('SELECT * FROM abonelikler WHERE aktif=1 ORDER BY id').all();
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
ad: r.ad,
|
||||
sehir: r.sehir || null,
|
||||
sektor: r.sektor || null,
|
||||
alimTuru: r.alim_turu || null,
|
||||
anahtar: r.anahtar || null,
|
||||
kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(),
|
||||
aktif: Boolean(r.aktif),
|
||||
sonGonderim: r.sonGonderim,
|
||||
sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(),
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function remove(id, email) {
|
||||
const db = open({ create: false });
|
||||
if (!db) return false;
|
||||
try {
|
||||
const r = db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id, email);
|
||||
return r.changes > 0;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function markSent(id, ihaleIdleri) {
|
||||
const db = open({ create: false });
|
||||
if (!db) return;
|
||||
try {
|
||||
db.prepare('UPDATE abonelikler SET sonGonderim=?, sonGorulenler=? WHERE id=?')
|
||||
.run(new Date().toISOString(), JSON.stringify(ihaleIdleri.slice(0, 400)), id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -14,6 +14,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { historyStats } from './history-store.mjs';
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -137,7 +138,24 @@ export function schedulerStatus() {
|
||||
};
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
// --- Otomatik e-posta bildirimi ---
|
||||
let bildirimCalisiyor = false;
|
||||
|
||||
async function bildirimTuru(tenderGetir) {
|
||||
if (bildirimCalisiyor) return;
|
||||
bildirimCalisiyor = true;
|
||||
try {
|
||||
const s = await bildirimleriIsle(tenderGetir);
|
||||
if (s.atlandi) console.log(`[scheduler] bildirim atlandı: ${s.atlandi}`);
|
||||
else console.log(`[scheduler] bildirim turu: ${s.abonelik} abonelik, ${s.gonderilen || 0} e-posta, ${s.hata || 0} hata`);
|
||||
} catch (e) {
|
||||
console.warn('[scheduler] bildirim hatası:', e.message);
|
||||
} finally {
|
||||
bildirimCalisiyor = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler({ tenderGetir } = {}) {
|
||||
if (process.env.SCAN_ENABLED === '0') {
|
||||
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
|
||||
return;
|
||||
@@ -158,4 +176,14 @@ export function startScheduler() {
|
||||
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
|
||||
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
|
||||
// Otomatik e-posta bildirimi
|
||||
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
|
||||
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
|
||||
} else {
|
||||
const nSaat = Number(process.env.NOTIFY_INTERVAL_SAAT || 6);
|
||||
console.log(`[scheduler] otomatik e-posta bildirimi her ${nSaat} saatte bir kontrol edilecek`);
|
||||
setTimeout(() => bildirimTuru(tenderGetir), 6 * 60 * 1000).unref?.();
|
||||
setInterval(() => bildirimTuru(tenderGetir), Math.max(1, nSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { getToken } from '../../services/auth';
|
||||
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, MapPin, Tag, Building2, Search } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Otomatik e-posta bildirimi abonelikleri.
|
||||
* Kullanıcı kriter kaydeder; sunucu periyodik olarak eşleşen YENİ ihaleleri
|
||||
* e-posta ile gönderir.
|
||||
*/
|
||||
export const NotifySubscriptions = () => {
|
||||
const {
|
||||
authUser, favoriteKurumlar,
|
||||
selectedCity, selectedSektor, selectedAlimTuru, searchQuery,
|
||||
} = useApp();
|
||||
|
||||
const [abonelikler, setAbonelikler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [kriter, setKriter] = useState({ sehir: '', sektor: '', alimTuru: '', anahtar: '', favoriKurumlar: false });
|
||||
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
fetch('/api/notify/subscriptions', { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setAbonelikler(d.abonelikler || []))
|
||||
.catch(() => setAbonelikler([]));
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
|
||||
// Ekrandaki aktif filtreleri forma taşı
|
||||
const filtreleriAl = () => setKriter({
|
||||
sehir: selectedCity || '',
|
||||
sektor: selectedSektor || '',
|
||||
alimTuru: selectedAlimTuru || '',
|
||||
anahtar: searchQuery || '',
|
||||
favoriKurumlar: false,
|
||||
});
|
||||
|
||||
const kaydet = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/notify/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({
|
||||
kriter: {
|
||||
sehir: kriter.sehir, sektor: kriter.sektor,
|
||||
alimTuru: kriter.alimTuru, anahtar: kriter.anahtar,
|
||||
kurumlar: kriter.favoriKurumlar ? favoriteKurumlar : [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
setAbonelikler(d.abonelikler);
|
||||
setKriter({ sehir: '', sektor: '', alimTuru: '', anahtar: '', favoriKurumlar: false });
|
||||
} catch (e) { setError(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const sil = async (id) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notify/subscriptions?id=${id}`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const d = await res.json();
|
||||
setAbonelikler(d.abonelikler || []);
|
||||
} catch (e) { setError(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const ozet = (a) => [
|
||||
a.sehir && `İl: ${a.sehir}`,
|
||||
a.sektor && `Sektör: ${a.sektor}`,
|
||||
a.alimTuru && `Tür: ${a.alimTuru}`,
|
||||
a.anahtar && `“${a.anahtar}”`,
|
||||
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
|
||||
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
|
||||
|
||||
if (!authUser) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<BellRing className="w-4 h-4 text-emerald-600" /> Otomatik E-posta Bildirimi
|
||||
</h3>
|
||||
{abonelikler && (
|
||||
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
||||
{abonelikler.length} kriter
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 leading-relaxed">
|
||||
Kaydettiğiniz kriterlere uyan <strong>yeni</strong> ihaleler çıktığında
|
||||
<strong> {authUser.email}</strong> adresine otomatik özet gönderilir. Sunucu bunu
|
||||
periyodik olarak kontrol eder; aynı ihale iki kez bildirilmez.
|
||||
</p>
|
||||
|
||||
{/* Mevcut abonelikler */}
|
||||
{abonelikler === null ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : abonelikler.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 border border-dashed border-slate-200 rounded-lg p-3">
|
||||
Henüz kriter eklemediniz. Aşağıdan ekleyin — boş bırakırsanız tüm yeni ihaleler bildirilir.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{abonelikler.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-slate-800 truncate">{ozet(a)}</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => sil(a.id)} disabled={busy} className="text-slate-400 hover:text-red-500 shrink-0" title="Kriteri sil">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yeni kriter */}
|
||||
<div className="pt-2 border-t border-slate-100 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wide">Yeni kriter</span>
|
||||
<button onClick={filtreleriAl} className="text-[11px] font-bold text-blue-600 hover:underline">
|
||||
Ekrandaki filtreleri kullan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="relative">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input value={kriter.sehir} onChange={(e) => setKriter({ ...kriter, sehir: e.target.value })}
|
||||
placeholder="İl (boş = tümü)"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
||||
</label>
|
||||
<label className="relative">
|
||||
<Tag className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input value={kriter.sektor} onChange={(e) => setKriter({ ...kriter, sektor: e.target.value })}
|
||||
placeholder="Sektör"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
||||
</label>
|
||||
<label className="relative">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input value={kriter.alimTuru} onChange={(e) => setKriter({ ...kriter, alimTuru: e.target.value })}
|
||||
placeholder="Alım türü"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
||||
</label>
|
||||
<label className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input value={kriter.anahtar} onChange={(e) => setKriter({ ...kriter, anahtar: e.target.value })}
|
||||
placeholder="Anahtar kelime"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{favoriteKurumlar?.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<input type="checkbox" checked={kriter.favoriKurumlar}
|
||||
onChange={(e) => setKriter({ ...kriter, favoriKurumlar: e.target.checked })}
|
||||
className="w-3.5 h-3.5 accent-emerald-600 rounded" />
|
||||
Yalnızca favori kurumlarım ({favoriteKurumlar.length})
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button onClick={kaydet} disabled={busy}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-2">
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
Bildirim Kriteri Ekle
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex gap-2">
|
||||
<Info className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-blue-900 leading-relaxed">
|
||||
İlk kriter eklendiğinde mevcut ihaleler <strong>bildirilmez</strong> — yalnızca temel alınır.
|
||||
Bundan sonra çıkan yeni ihaleler gönderilir, böylece ilk gün yüzlerce eski ilanla dolmaz.
|
||||
Sonuçlanmış arşiv kayıtları bildirime girmez.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useApp } from '../../context/AppContext';
|
||||
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { getToken } from '../../services/auth';
|
||||
import { NotifySubscriptions } from './NotifySubscriptions';
|
||||
|
||||
export const PwaSettings = () => {
|
||||
const { userProfile, authUser, tenders, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp();
|
||||
@@ -249,6 +250,9 @@ export const PwaSettings = () => {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Otomatik bildirim abonelikleri */}
|
||||
<NotifySubscriptions />
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user