From f73c7bd6cb34d370362f8ae83416189b58e99630 Mon Sep 17 00:00:00 2001 From: ht Date: Sat, 29 Aug 2026 10:19:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20konu=20bazl=C4=B1=20bildirim=20+=20ger?= =?UTF-8?q?=C3=A7ek=20site=20tarama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Bildirim artık KONU odaklı (il değil) - Kullanıcı hangi konularda ihale alabildiğini seçer; varsayılan tüm Türkiye - İl/alım türü/favori kurum 'daha da daralt' altında isteğe bağlı - notify-store: konular kolonu + otomatik şema geçişi - notifier: konu başlıkları ihale başlığı/sektör/branş/kalemlerde aranır 2) Gerçek firma sitesi tarama (webScraperService mock'unun yerine) - server/site-scan.mjs: siteyi GERÇEKTEN çeker, ana sayfa + ürün/hizmet/ hakkımızda iç sayfalarını tarar, HTML'i metne çevirir, AI ile firmanın tedarik edebildiği ihale konularını çıkarır ('uydurma' yasaklı prompt) - /api/company/scan (auth'lu) - ai.mjs: systemOverride desteği (farklı görevler için ayrı sistem promptu) - Arayüz: 'Sitenizi tarayıp konuları otomatik önerelim' → tıklayarak ekleme Not: eski webScraperService.js sabit anahtar-kelime sözlüğüydü; domain adında 'tanriverdi' geçince tıbbi ürün listesi uyduruyordu. --- server/ai.mjs | 27 +- server/index.js | 14 + server/notifier.mjs | 6 + server/notify-store.mjs | 13 +- server/site-scan.mjs | 135 ++++++++++ src/components/tools/NotifySubscriptions.jsx | 269 ++++++++++++------- 6 files changed, 350 insertions(+), 114 deletions(-) create mode 100644 server/site-scan.mjs diff --git a/server/ai.mjs b/server/ai.mjs index 44f7747..751a76a 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -118,7 +118,7 @@ function buildUserPrompt({ text, fileName, tender }) { } // --- OpenRouter (OpenAI uyumlu) --- -async function callOpenRouter(model, key, userPrompt, signal) { +async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT) { const res = await fetch(OPENROUTER_URL, { method: 'POST', headers: { @@ -131,7 +131,7 @@ async function callOpenRouter(model, key, userPrompt, signal) { body: JSON.stringify({ model, messages: [ - { role: 'system', content: SYSTEM_PROMPT }, + { role: 'system', content: sistem }, { role: 'user', content: userPrompt }, ], temperature: 0.2, @@ -146,14 +146,14 @@ async function callOpenRouter(model, key, userPrompt, signal) { return data?.choices?.[0]?.message?.content || ''; } -async function callOpenAiStyle(p, key, model, userPrompt, signal) { +async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { const res = await fetch(p.url, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: [ - { role: 'system', content: SYSTEM_PROMPT }, + { role: 'system', content: sistem }, { role: 'user', content: userPrompt }, ], temperature: 0.2, @@ -166,13 +166,13 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal) { return data?.choices?.[0]?.message?.content || ''; } -async function callGemini(p, key, model, userPrompt, signal) { +async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, + systemInstruction: { parts: [{ text: sistem }] }, contents: [{ role: 'user', parts: [{ text: userPrompt }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' }, }), @@ -183,14 +183,14 @@ async function callGemini(p, key, model, userPrompt, signal) { return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || ''; } -async function callAnthropic(p, key, model, userPrompt, signal) { +async function callAnthropic(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { const res = await fetch(p.url, { method: 'POST', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ model, max_tokens: 4000, - system: SYSTEM_PROMPT, + system: sistem, messages: [{ role: 'user', content: userPrompt }], }), signal, @@ -212,10 +212,11 @@ function extractJson(raw) { } /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ -export async function analyzeSpec({ text, fileName, tender, chain }) { +export async function analyzeSpec({ text, fileName, tender, chain, systemOverride }) { const order = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude']) .filter((id) => PROVIDERS[id]); const userPrompt = buildUserPrompt({ text, fileName, tender }); + const sistem = systemOverride || SYSTEM_PROMPT; const attempts = []; for (const id of order) { @@ -230,13 +231,13 @@ export async function analyzeSpec({ text, fileName, tender, chain }) { try { let raw; if (route.yol === 'openrouter') { - raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal); + raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem); } else if (p.style === 'gemini') { - raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal); + raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem); } else if (p.style === 'anthropic') { - raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal); + raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem); } else { - raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal); + raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem); } const result = extractJson(raw); attempts.push({ id, ok: true, yol: route.yol }); diff --git a/server/index.js b/server/index.js index 8f5c396..59b8158 100644 --- a/server/index.js +++ b/server/index.js @@ -27,6 +27,7 @@ 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'; +import { scanSite } from './site-scan.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -590,6 +591,19 @@ const server = http.createServer(async (req, res) => { } } + // --- Firma web sitesi tarama (gerçek fetch + AI) --- + if (url.pathname === '/api/company/scan' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const body = await readBody(req); + const out = await scanSite(body.url); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + // --- Otomatik bildirim abonelikleri --- if (url.pathname === '/api/notify/subscriptions') { const user = userFromAuthHeader(req.headers['authorization']); diff --git a/server/notifier.mjs b/server/notifier.mjs index 429ae88..8a40089 100644 --- a/server/notifier.mjs +++ b/server/notifier.mjs @@ -23,6 +23,11 @@ export function eslesiyorMu(t, a) { const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`); if (!alan.includes(q)) return false; } + // Konu başlıkları: herhangi biri ihale metninde geçiyorsa eşleşir + if (a.konular?.length) { + const alan = norm(`${t.baslik || ''} ${t.sektor || ''} ${t.brans || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`); + if (!a.konular.some((k) => alan.includes(norm(k)))) return false; + } if (a.kurumlar?.length) { const kurum = norm(t.kurum); if (!a.kurumlar.some((k) => norm(k) === kurum)) return false; @@ -76,6 +81,7 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { a.sektor && `Sektör: ${a.sektor}`, a.alimTuru && `Tür: ${a.alimTuru}`, a.anahtar && `Anahtar: ${a.anahtar}`, + a.konular?.length && `Konular: ${a.konular.slice(0, 4).join(', ')}${a.konular.length > 4 ? '…' : ''}`, a.kurumlar?.length && `${a.kurumlar.length} favori kurum`, ].filter(Boolean).join(' · ') || 'Tüm ihaleler'; diff --git a/server/notify-store.mjs b/server/notify-store.mjs index f7d4793..53e5cec 100644 --- a/server/notify-store.mjs +++ b/server/notify-store.mjs @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS abonelikler ( alim_turu TEXT, anahtar TEXT, kurumlar TEXT, -- JSON: favori kurum listesi + konular TEXT, -- JSON: konu başlıkları (kullanıcı seçer) aktif INTEGER NOT NULL DEFAULT 1, sonGonderim TEXT, sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri @@ -50,6 +51,10 @@ function open({ create = true } = {}) { } const db = new DatabaseSync(DB_PATH); db.exec(SCHEMA); + try { + const k = db.prepare('PRAGMA table_info(abonelikler)').all().map((c) => c.name); + if (!k.includes('konular')) db.exec('ALTER TABLE abonelikler ADD COLUMN konular TEXT'); + } catch (e) { console.warn('[notify-store] şema geçişi:', e.message); } return db; } @@ -60,10 +65,10 @@ export function subscribe(email, kriter = {}, ad = null) { 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,?) + INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, konular, aktif, olusturma) + VALUES (?,?,?,?,?,?,?,?,1,?) ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET - aktif=1, kurumlar=excluded.kurumlar, ad=excluded.ad + aktif=1, kurumlar=excluded.kurumlar, konular=excluded.konular, ad=excluded.ad `).run( email, ad, @@ -72,6 +77,7 @@ export function subscribe(email, kriter = {}, ad = null) { bosaNull(kriter.alimTuru), bosaNull(kriter.anahtar), JSON.stringify(kriter.kurumlar || []), + JSON.stringify(kriter.konular || []), new Date().toISOString() ); return list(email); @@ -96,6 +102,7 @@ export function list(email) { alimTuru: r.alim_turu || null, anahtar: r.anahtar || null, kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(), + konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(), aktif: Boolean(r.aktif), sonGonderim: r.sonGonderim, sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(), diff --git a/server/site-scan.mjs b/server/site-scan.mjs new file mode 100644 index 0000000..698b564 --- /dev/null +++ b/server/site-scan.mjs @@ -0,0 +1,135 @@ +/** + * Gerçek firma web sitesi tarama + AI ile yetkinlik çıkarımı. + * + * Eski webScraperService.js sabit bir anahtar-kelime sözlüğüydü (domainde + * "medikal" geçince tıbbi ürün listesi uyduruyordu). Bu modül siteyi + * GERÇEKTEN çeker, metnini çıkarır ve AI ile ne tedarik edebildiğini belirler. + * + * Bağımlılık yok (Node 18+ fetch). + */ + +import { analyzeSpec, aiConfigured } from './ai.mjs'; + +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +const MAX_BYTE = 1.5 * 1024 * 1024; + +const ENT = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; + +function htmlToText(html) { + return String(html || '') + .replace(/<\s*(script|style|noscript|svg)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ') + .replace(/<\s*\/\s*(p|div|li|h[1-6]|tr|section)\s*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z#0-9]+;/gi, (m) => ENT[m.toLowerCase()] ?? ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function normalizeUrl(input) { + let u = String(input || '').trim(); + if (!u) throw new Error('Adres gerekli'); + if (!/^https?:\/\//i.test(u)) u = 'https://' + u; + const url = new URL(u); + if (!/^https?:$/.test(url.protocol)) throw new Error('Yalnızca http/https desteklenir'); + return url; +} + +async function fetchPage(url, timeout = 20000) { + const res = await fetch(url, { + headers: { 'User-Agent': UA, 'Accept-Language': 'tr,en;q=0.8', Accept: 'text/html,application/xhtml+xml' }, + redirect: 'follow', + signal: AbortSignal.timeout(timeout), + }); + if (!res.ok) throw new Error(`Sayfa alınamadı (HTTP ${res.status})`); + const tip = res.headers.get('content-type') || ''; + if (!/text\/html|application\/xhtml/i.test(tip)) throw new Error('Adres bir web sayfası değil'); + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_BYTE) throw new Error('Sayfa çok büyük'); + return new TextDecoder('utf-8').decode(buf); +} + +// Ana sayfadan iç sayfa adaylarını çıkar (hakkımızda, ürünler, hizmetler…) +const IC_SAYFA = /(urun|ürün|hizmet|product|service|hakk|about|katalog|cozum|çözüm|referans|belge|sertifika)/i; + +function icLinkler(html, base) { + const out = new Set(); + const re = /]+href=["']([^"'#]+)["']/gi; + let m; + while ((m = re.exec(html)) && out.size < 40) { + try { + const u = new URL(m[1], base); + if (u.hostname !== new URL(base).hostname) continue; + if (!IC_SAYFA.test(u.pathname)) continue; + u.hash = ''; + out.add(u.href); + } catch { /* geçersiz link */ } + } + return [...out].slice(0, 4); +} + +const SISTEM = `Sen Türkiye kamu ihalelerine katılan firmaların yetkinliklerini belirleyen bir analistsin. +Sana bir firmanın web sitesinden çıkarılmış metin verilecek. +YALNIZCA geçerli JSON döndür, başka metin yazma. Şema: +{ + "firmaAdi": "sitede geçen firma adı veya null", + "ozet": "firmanın ne yaptığına dair 1-2 cümle", + "sektorler": ["firmanın faaliyet gösterdiği sektörler"], + "urunler": ["tedarik edebildiği somut ürün/hizmetler"], + "sertifikalar": ["sitede belirtilen belge/sertifikalar"], + "ihaleKonulari": ["bu firmanın teklif verebileceği ihale konu başlıkları — kısa, aranabilir terimler"] +} +KURALLAR: +- Yalnızca metinde GERÇEKTEN geçen bilgileri yaz. Uydurma. +- Metinde yoksa boş dizi veya null bırak. +- ihaleKonulari en fazla 12 madde, her biri 1-4 kelime, ihale ilanlarında aranabilecek terimler olsun + (ör. "tıbbi sarf malzeme", "iş güvenliği ekipmanı", "temizlik kimyasalı").`; + +/** + * Siteyi çeker ve AI ile yetkinlik çıkarır. + * @returns {{url, tarananSayfa, karakter, sonuc}} + */ +export async function scanSite(inputUrl) { + if (!aiConfigured()) { + throw new Error('Site analizi için AI yapılandırılmamış (OPENROUTER_API_KEY gerekli).'); + } + const url = normalizeUrl(inputUrl); + const ana = await fetchPage(url.href); + const sayfalar = [{ url: url.href, metin: htmlToText(ana) }]; + + // Birkaç iç sayfayı da tara (ürün/hizmet sayfaları en bilgilendirici) + for (const link of icLinkler(ana, url.href)) { + try { + const html = await fetchPage(link, 15000); + const metin = htmlToText(html); + if (metin.length > 200) sayfalar.push({ url: link, metin }); + } catch { /* iç sayfa atlanabilir */ } + } + + const birlesik = sayfalar + .map((s) => `--- ${s.url} ---\n${s.metin.slice(0, 12000)}`) + .join('\n\n') + .slice(0, 45000); + + if (birlesik.replace(/\s/g, '').length < 200) { + throw new Error('Siteden yeterli metin çıkarılamadı (JavaScript ile yüklenen bir site olabilir).'); + } + + const out = await analyzeSpec({ + text: birlesik, + fileName: url.hostname, + tender: null, + chain: null, + systemOverride: SISTEM, + }); + + return { + url: url.href, + tarananSayfa: sayfalar.length, + sayfalar: sayfalar.map((s) => s.url), + karakter: birlesik.length, + model: out.model, + sonuc: out.result, + }; +} diff --git a/src/components/tools/NotifySubscriptions.jsx b/src/components/tools/NotifySubscriptions.jsx index 7c6f00a..26aafc9 100644 --- a/src/components/tools/NotifySubscriptions.jsx +++ b/src/components/tools/NotifySubscriptions.jsx @@ -1,23 +1,30 @@ 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'; +import { SECTORS_LIST } from '../../data/constants'; +import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } 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. + * Otomatik e-posta bildirimi — KONU odaklı. + * Kullanıcı hangi konularda ihale alabildiğini seçer; il/kurum gibi daraltmalar + * isteğe bağlıdır (varsayılan: tüm Türkiye). */ export const NotifySubscriptions = () => { - const { - authUser, favoriteKurumlar, - selectedCity, selectedSektor, selectedAlimTuru, searchQuery, - } = useApp(); + const { authUser, favoriteKurumlar, userProfile } = 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 [konular, setKonular] = useState([]); + const [ozelKonu, setOzelKonu] = useState(''); + const [gelismis, setGelismis] = useState(false); + const [daralt, setDaralt] = useState({ sehir: '', alimTuru: '', favoriKurumlar: false }); + + // Site tarama + const [site, setSite] = useState(userProfile?.website || ''); + const [tarama, setTarama] = useState(null); + const [taraniyor, setTaraniyor] = useState(false); + const [taramaHata, setTaramaHata] = useState(null); const yukle = () => { if (!authUser) return; @@ -28,16 +35,31 @@ export const NotifySubscriptions = () => { }; useEffect(yukle, [authUser]); - // Ekrandaki aktif filtreleri forma taşı - const filtreleriAl = () => setKriter({ - sehir: selectedCity || '', - sektor: selectedSektor || '', - alimTuru: selectedAlimTuru || '', - anahtar: searchQuery || '', - favoriKurumlar: false, - }); + const konuEkle = (k) => { + const t = String(k || '').trim(); + if (!t || konular.some((x) => x.toLocaleLowerCase('tr-TR') === t.toLocaleLowerCase('tr-TR'))) return; + setKonular([...konular, t]); + }; + const konuCikar = (k) => setKonular(konular.filter((x) => x !== k)); + + const siteyiTara = async () => { + setTaraniyor(true); setTaramaHata(null); setTarama(null); + try { + const res = await fetch('/api/company/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ url: site }), + }); + const d = await res.json(); + if (!d.success) throw new Error(d.error); + setTarama(d); + } catch (e) { + setTaramaHata(e.message); + } finally { setTaraniyor(false); } + }; const kaydet = async () => { + if (!konular.length) { setError('En az bir konu seçin.'); return; } setBusy(true); setError(null); try { const res = await fetch('/api/notify/subscriptions', { @@ -45,16 +67,17 @@ export const NotifySubscriptions = () => { 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 : [], + konular, + sehir: daralt.sehir, + alimTuru: daralt.alimTuru, + kurumlar: daralt.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 }); + setKonular([]); setDaralt({ sehir: '', alimTuru: '', favoriKurumlar: false }); } catch (e) { setError(e.message); } finally { setBusy(false); } }; @@ -72,52 +95,48 @@ export const NotifySubscriptions = () => { }; 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.konular?.length ? a.konular.join(' · ') : null, + a.sehir && `📍 ${a.sehir}`, + a.alimTuru && `(${a.alimTuru})`, a.kurumlar?.length && `${a.kurumlar.length} favori kurum`, - ].filter(Boolean).join(' · ') || 'Tüm ihaleler'; + ].filter(Boolean).join(' ') || 'Tüm ihaleler'; if (!authUser) return null; + const oneriler = tarama?.sonuc?.ihaleKonulari || []; + return ( -
+

Otomatik E-posta Bildirimi

{abonelikler && ( - {abonelikler.length} kriter + {abonelikler.length} kayıt )}

- Kaydettiğiniz kriterlere uyan yeni ihaleler çıktığında - {authUser.email} adresine otomatik özet gönderilir. Sunucu bunu - periyodik olarak kontrol eder; aynı ihale iki kez bildirilmez. + Hangi konularda ihale alabiliyorsanız onları seçin — tüm Türkiye taranır, + yalnızca size uyan yeni ihaleler {authUser.email} adresine gelir. Gereksiz ilan gönderilmez.

- {/* Mevcut abonelikler */} + {/* Mevcut kayıtlar */} {abonelikler === null ? (
yükleniyor…
- ) : abonelikler.length === 0 ? ( -

- Henüz kriter eklemediniz. Aşağıdan ekleyin — boş bırakırsanız tüm yeni ihaleler bildirilir. -

- ) : ( + ) : abonelikler.length > 0 && (
{abonelikler.map((a) => ( -
+
-

{ozet(a)}

-

+

{ozet(a)}

+

{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}

-
@@ -125,71 +144,125 @@ export const NotifySubscriptions = () => {
)} - {/* Yeni kriter */} -
-
- Yeni kriter -
-
- - - - -
+ {taramaHata &&

⚠ {taramaHata}

} - {favoriteKurumlar?.length > 0 && ( - - )} - - - - {error && ( -
- -

{error}

+ {tarama && ( +
+

+ {tarama.tarananSayfa} sayfa tarandı ({tarama.karakter.toLocaleString('tr-TR')} karakter). + {tarama.sonuc?.ozet && <> {tarama.sonuc.ozet}} +

+ {oneriler.length > 0 && ( + <> +

Önerilen konular — eklemek için tıklayın:

+
+ {oneriler.map((k) => ( + + ))} +
+ + )}
)}
-
- -

- İlk kriter eklendiğinde mevcut ihaleler bildirilmez — 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. + {/* Seçilen konular */} +

+

Seçilen konular

+ {konular.length === 0 ? ( +

Henüz konu seçilmedi.

+ ) : ( +
+ {konular.map((k) => ( + + {k} + + + ))} +
+ )} + +
+ setOzelKonu(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { konuEkle(ozelKonu); setOzelKonu(''); } }} + placeholder="Kendi konunuzu yazın ve Enter'a basın" + className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" /> + +
+ +

Hazır sektörler:

+
+ {SECTORS_LIST.map((s) => ( + + ))} +
+
+ + {/* Gelişmiş daraltma (isteğe bağlı) */} +
+ + {gelismis && ( +
+ setDaralt({ ...daralt, sehir: e.target.value })} + placeholder="Yalnızca bu il" className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" /> + setDaralt({ ...daralt, alimTuru: e.target.value })} + placeholder="Alım türü (ör. Doğrudan Temin)" className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" /> + {favoriteKurumlar?.length > 0 && ( + + )} +
+ )} +
+ + + + {error && ( +
+ +

{error}

+
+ )} + +
+ +

+ İlk kayıtta mevcut ihaleler bildirilmez — yalnızca temel alınır, sonrasında çıkan + yeni ihaleler gönderilir. Aynı ihale iki kez gönderilmez; sonuçlanmış arşiv kayıtları bildirime girmez.