feat: konu bazlı bildirim + gerçek site tarama
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.
This commit is contained in:
+14
-13
@@ -118,7 +118,7 @@ function buildUserPrompt({ text, fileName, tender }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- OpenRouter (OpenAI uyumlu) ---
|
// --- 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, {
|
const res = await fetch(OPENROUTER_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -131,7 +131,7 @@ async function callOpenRouter(model, key, userPrompt, signal) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
{ role: 'system', content: sistem },
|
||||||
{ role: 'user', content: userPrompt },
|
{ role: 'user', content: userPrompt },
|
||||||
],
|
],
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
@@ -146,14 +146,14 @@ async function callOpenRouter(model, key, userPrompt, signal) {
|
|||||||
return data?.choices?.[0]?.message?.content || '';
|
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, {
|
const res = await fetch(p.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
{ role: 'system', content: sistem },
|
||||||
{ role: 'user', content: userPrompt },
|
{ role: 'user', content: userPrompt },
|
||||||
],
|
],
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
@@ -166,13 +166,13 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal) {
|
|||||||
return data?.choices?.[0]?.message?.content || '';
|
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 url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
systemInstruction: { parts: [{ text: sistem }] },
|
||||||
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
|
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
|
||||||
generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' },
|
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('') || '';
|
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, {
|
const res = await fetch(p.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' },
|
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
max_tokens: 4000,
|
max_tokens: 4000,
|
||||||
system: SYSTEM_PROMPT,
|
system: sistem,
|
||||||
messages: [{ role: 'user', content: userPrompt }],
|
messages: [{ role: 'user', content: userPrompt }],
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
@@ -212,10 +212,11 @@ function extractJson(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */
|
/** 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'])
|
const order = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude'])
|
||||||
.filter((id) => PROVIDERS[id]);
|
.filter((id) => PROVIDERS[id]);
|
||||||
const userPrompt = buildUserPrompt({ text, fileName, tender });
|
const userPrompt = buildUserPrompt({ text, fileName, tender });
|
||||||
|
const sistem = systemOverride || SYSTEM_PROMPT;
|
||||||
const attempts = [];
|
const attempts = [];
|
||||||
|
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
@@ -230,13 +231,13 @@ export async function analyzeSpec({ text, fileName, tender, chain }) {
|
|||||||
try {
|
try {
|
||||||
let raw;
|
let raw;
|
||||||
if (route.yol === 'openrouter') {
|
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') {
|
} 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') {
|
} 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 {
|
} 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);
|
const result = extractJson(raw);
|
||||||
attempts.push({ id, ok: true, yol: route.yol });
|
attempts.push({ id, ok: true, yol: route.yol });
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { searchKik, kararMetni } from './kik.mjs';
|
|||||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.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 { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
|
||||||
import { bildirimleriIsle } from './notifier.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;
|
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
// 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 ---
|
// --- Otomatik bildirim abonelikleri ---
|
||||||
if (url.pathname === '/api/notify/subscriptions') {
|
if (url.pathname === '/api/notify/subscriptions') {
|
||||||
const user = userFromAuthHeader(req.headers['authorization']);
|
const user = userFromAuthHeader(req.headers['authorization']);
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export function eslesiyorMu(t, a) {
|
|||||||
const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
|
const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
|
||||||
if (!alan.includes(q)) return false;
|
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) {
|
if (a.kurumlar?.length) {
|
||||||
const kurum = norm(t.kurum);
|
const kurum = norm(t.kurum);
|
||||||
if (!a.kurumlar.some((k) => norm(k) === kurum)) return false;
|
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.sektor && `Sektör: ${a.sektor}`,
|
||||||
a.alimTuru && `Tür: ${a.alimTuru}`,
|
a.alimTuru && `Tür: ${a.alimTuru}`,
|
||||||
a.anahtar && `Anahtar: ${a.anahtar}`,
|
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`,
|
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
|
||||||
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
|
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS abonelikler (
|
|||||||
alim_turu TEXT,
|
alim_turu TEXT,
|
||||||
anahtar TEXT,
|
anahtar TEXT,
|
||||||
kurumlar TEXT, -- JSON: favori kurum listesi
|
kurumlar TEXT, -- JSON: favori kurum listesi
|
||||||
|
konular TEXT, -- JSON: konu başlıkları (kullanıcı seçer)
|
||||||
aktif INTEGER NOT NULL DEFAULT 1,
|
aktif INTEGER NOT NULL DEFAULT 1,
|
||||||
sonGonderim TEXT,
|
sonGonderim TEXT,
|
||||||
sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri
|
sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri
|
||||||
@@ -50,6 +51,10 @@ function open({ create = true } = {}) {
|
|||||||
}
|
}
|
||||||
const db = new DatabaseSync(DB_PATH);
|
const db = new DatabaseSync(DB_PATH);
|
||||||
db.exec(SCHEMA);
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +65,10 @@ export function subscribe(email, kriter = {}, ad = null) {
|
|||||||
if (!db) throw new Error('Bildirim deposu kullanılamıyor');
|
if (!db) throw new Error('Bildirim deposu kullanılamıyor');
|
||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, aktif, olusturma)
|
INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, konular, aktif, olusturma)
|
||||||
VALUES (?,?,?,?,?,?,?,1,?)
|
VALUES (?,?,?,?,?,?,?,?,1,?)
|
||||||
ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET
|
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(
|
`).run(
|
||||||
email,
|
email,
|
||||||
ad,
|
ad,
|
||||||
@@ -72,6 +77,7 @@ export function subscribe(email, kriter = {}, ad = null) {
|
|||||||
bosaNull(kriter.alimTuru),
|
bosaNull(kriter.alimTuru),
|
||||||
bosaNull(kriter.anahtar),
|
bosaNull(kriter.anahtar),
|
||||||
JSON.stringify(kriter.kurumlar || []),
|
JSON.stringify(kriter.kurumlar || []),
|
||||||
|
JSON.stringify(kriter.konular || []),
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
);
|
);
|
||||||
return list(email);
|
return list(email);
|
||||||
@@ -96,6 +102,7 @@ export function list(email) {
|
|||||||
alimTuru: r.alim_turu || null,
|
alimTuru: r.alim_turu || null,
|
||||||
anahtar: r.anahtar || null,
|
anahtar: r.anahtar || null,
|
||||||
kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(),
|
kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(),
|
||||||
|
konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(),
|
||||||
aktif: Boolean(r.aktif),
|
aktif: Boolean(r.aktif),
|
||||||
sonGonderim: r.sonGonderim,
|
sonGonderim: r.sonGonderim,
|
||||||
sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(),
|
sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(),
|
||||||
|
|||||||
@@ -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(/<br\s*\/?>/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 = /<a[^>]+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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { getToken } from '../../services/auth';
|
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.
|
* Otomatik e-posta bildirimi — KONU odaklı.
|
||||||
* Kullanıcı kriter kaydeder; sunucu periyodik olarak eşleşen YENİ ihaleleri
|
* Kullanıcı hangi konularda ihale alabildiğini seçer; il/kurum gibi daraltmalar
|
||||||
* e-posta ile gönderir.
|
* isteğe bağlıdır (varsayılan: tüm Türkiye).
|
||||||
*/
|
*/
|
||||||
export const NotifySubscriptions = () => {
|
export const NotifySubscriptions = () => {
|
||||||
const {
|
const { authUser, favoriteKurumlar, userProfile } = useApp();
|
||||||
authUser, favoriteKurumlar,
|
|
||||||
selectedCity, selectedSektor, selectedAlimTuru, searchQuery,
|
|
||||||
} = useApp();
|
|
||||||
|
|
||||||
const [abonelikler, setAbonelikler] = useState(null);
|
const [abonelikler, setAbonelikler] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
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 = () => {
|
const yukle = () => {
|
||||||
if (!authUser) return;
|
if (!authUser) return;
|
||||||
@@ -28,16 +35,31 @@ export const NotifySubscriptions = () => {
|
|||||||
};
|
};
|
||||||
useEffect(yukle, [authUser]);
|
useEffect(yukle, [authUser]);
|
||||||
|
|
||||||
// Ekrandaki aktif filtreleri forma taşı
|
const konuEkle = (k) => {
|
||||||
const filtreleriAl = () => setKriter({
|
const t = String(k || '').trim();
|
||||||
sehir: selectedCity || '',
|
if (!t || konular.some((x) => x.toLocaleLowerCase('tr-TR') === t.toLocaleLowerCase('tr-TR'))) return;
|
||||||
sektor: selectedSektor || '',
|
setKonular([...konular, t]);
|
||||||
alimTuru: selectedAlimTuru || '',
|
};
|
||||||
anahtar: searchQuery || '',
|
const konuCikar = (k) => setKonular(konular.filter((x) => x !== k));
|
||||||
favoriKurumlar: false,
|
|
||||||
|
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 () => {
|
const kaydet = async () => {
|
||||||
|
if (!konular.length) { setError('En az bir konu seçin.'); return; }
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/notify/subscriptions', {
|
const res = await fetch('/api/notify/subscriptions', {
|
||||||
@@ -45,16 +67,17 @@ export const NotifySubscriptions = () => {
|
|||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
kriter: {
|
kriter: {
|
||||||
sehir: kriter.sehir, sektor: kriter.sektor,
|
konular,
|
||||||
alimTuru: kriter.alimTuru, anahtar: kriter.anahtar,
|
sehir: daralt.sehir,
|
||||||
kurumlar: kriter.favoriKurumlar ? favoriteKurumlar : [],
|
alimTuru: daralt.alimTuru,
|
||||||
|
kurumlar: daralt.favoriKurumlar ? favoriteKurumlar : [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!d.success) throw new Error(d.error);
|
if (!d.success) throw new Error(d.error);
|
||||||
setAbonelikler(d.abonelikler);
|
setAbonelikler(d.abonelikler);
|
||||||
setKriter({ sehir: '', sektor: '', alimTuru: '', anahtar: '', favoriKurumlar: false });
|
setKonular([]); setDaralt({ sehir: '', alimTuru: '', favoriKurumlar: false });
|
||||||
} catch (e) { setError(e.message); }
|
} catch (e) { setError(e.message); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
@@ -72,52 +95,48 @@ export const NotifySubscriptions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ozet = (a) => [
|
const ozet = (a) => [
|
||||||
a.sehir && `İl: ${a.sehir}`,
|
a.konular?.length ? a.konular.join(' · ') : null,
|
||||||
a.sektor && `Sektör: ${a.sektor}`,
|
a.sehir && `📍 ${a.sehir}`,
|
||||||
a.alimTuru && `Tür: ${a.alimTuru}`,
|
a.alimTuru && `(${a.alimTuru})`,
|
||||||
a.anahtar && `“${a.anahtar}”`,
|
|
||||||
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
|
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
|
||||||
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
|
].filter(Boolean).join(' ') || 'Tüm ihaleler';
|
||||||
|
|
||||||
if (!authUser) return null;
|
if (!authUser) return null;
|
||||||
|
|
||||||
|
const oneriler = tarama?.sonuc?.ihaleKonulari || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
<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">
|
<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
|
<BellRing className="w-4 h-4 text-emerald-600" /> Otomatik E-posta Bildirimi
|
||||||
</h3>
|
</h3>
|
||||||
{abonelikler && (
|
{abonelikler && (
|
||||||
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
||||||
{abonelikler.length} kriter
|
{abonelikler.length} kayıt
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-600 leading-relaxed">
|
<p className="text-xs text-slate-600 leading-relaxed">
|
||||||
Kaydettiğiniz kriterlere uyan <strong>yeni</strong> ihaleler çıktığında
|
Hangi <strong>konularda</strong> ihale alabiliyorsanız onları seçin — <strong>tüm Türkiye</strong> taranır,
|
||||||
<strong> {authUser.email}</strong> adresine otomatik özet gönderilir. Sunucu bunu
|
yalnızca size uyan yeni ihaleler <strong>{authUser.email}</strong> adresine gelir. Gereksiz ilan gönderilmez.
|
||||||
periyodik olarak kontrol eder; aynı ihale iki kez bildirilmez.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Mevcut abonelikler */}
|
{/* Mevcut kayıtlar */}
|
||||||
{abonelikler === null ? (
|
{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>
|
<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 ? (
|
) : 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">
|
<div className="space-y-2">
|
||||||
{abonelikler.map((a) => (
|
{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 key={a.id} className="flex items-start justify-between gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-slate-800 truncate">{ozet(a)}</p>
|
<p className="text-xs font-bold text-slate-800">{ozet(a)}</p>
|
||||||
<p className="text-[10px] text-slate-400">
|
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||||
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}
|
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => sil(a.id)} disabled={busy} className="text-slate-400 hover:text-red-500 shrink-0" title="Kriteri sil">
|
<button onClick={() => sil(a.id)} disabled={busy} className="text-slate-400 hover:text-red-500 shrink-0 mt-0.5" title="Sil">
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,55 +144,111 @@ export const NotifySubscriptions = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Yeni kriter */}
|
{/* Site tarama → konu önerisi */}
|
||||||
<div className="pt-2 border-t border-slate-100 space-y-2">
|
<div className="bg-blue-50/60 border border-blue-200 rounded-xl p-3.5 space-y-2.5">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-[11px] font-bold text-blue-900 flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wide">Yeni kriter</span>
|
<Globe className="w-3.5 h-3.5" /> Sitenizi tarayıp konuları otomatik önerelim
|
||||||
<button onClick={filtreleriAl} className="text-[11px] font-bold text-blue-600 hover:underline">
|
</p>
|
||||||
Ekrandaki filtreleri kullan
|
<div className="flex gap-2">
|
||||||
|
<input value={site} onChange={(e) => setSite(e.target.value)}
|
||||||
|
placeholder="firmasitesi.com"
|
||||||
|
className="flex-1 bg-white border border-blue-200 rounded-lg px-2.5 py-2 text-xs" />
|
||||||
|
<button onClick={siteyiTara} disabled={taraniyor || !site.trim()}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-3.5 rounded-lg flex items-center gap-1.5">
|
||||||
|
{taraniyor ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||||
|
{taraniyor ? 'Taranıyor…' : 'Tara'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{taramaHata && <p className="text-[11px] text-red-700">⚠ {taramaHata}</p>}
|
||||||
<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" />
|
{tarama && (
|
||||||
<input value={kriter.sehir} onChange={(e) => setKriter({ ...kriter, sehir: e.target.value })}
|
<div className="space-y-2">
|
||||||
placeholder="İl (boş = tümü)"
|
<p className="text-[11px] text-blue-900">
|
||||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
<strong>{tarama.tarananSayfa} sayfa</strong> tarandı ({tarama.karakter.toLocaleString('tr-TR')} karakter).
|
||||||
</label>
|
{tarama.sonuc?.ozet && <> {tarama.sonuc.ozet}</>}
|
||||||
<label className="relative">
|
</p>
|
||||||
<Tag className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
{oneriler.length > 0 && (
|
||||||
<input value={kriter.sektor} onChange={(e) => setKriter({ ...kriter, sektor: e.target.value })}
|
<>
|
||||||
placeholder="Sektör"
|
<p className="text-[10px] text-blue-700 font-semibold">Önerilen konular — eklemek için tıklayın:</p>
|
||||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</label>
|
{oneriler.map((k) => (
|
||||||
<label className="relative">
|
<button key={k} onClick={() => konuEkle(k)}
|
||||||
<Building2 className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
className="text-[11px] bg-white hover:bg-blue-100 border border-blue-300 text-blue-900 px-2 py-1 rounded-md font-medium">
|
||||||
<input value={kriter.alimTuru} onChange={(e) => setKriter({ ...kriter, alimTuru: e.target.value })}
|
+ {k}
|
||||||
placeholder="Alım türü"
|
</button>
|
||||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
|
))}
|
||||||
</label>
|
</div>
|
||||||
<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 })}
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Seçilen konular */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] font-bold text-slate-700 uppercase tracking-wide">Seçilen konular</p>
|
||||||
|
{konular.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-slate-400">Henüz konu seçilmedi.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{konular.map((k) => (
|
||||||
|
<span key={k} className="text-[11px] bg-emerald-50 border border-emerald-300 text-emerald-900 px-2 py-1 rounded-md font-semibold flex items-center gap-1">
|
||||||
|
{k}
|
||||||
|
<button onClick={() => konuCikar(k)} className="text-emerald-700 hover:text-red-600"><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input value={ozelKonu} onChange={(e) => 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" />
|
||||||
|
<button onClick={() => { konuEkle(ozelKonu); setOzelKonu(''); }}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs px-3 rounded-lg">Ekle</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-slate-400 pt-1">Hazır sektörler:</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SECTORS_LIST.map((s) => (
|
||||||
|
<button key={s} onClick={() => konuEkle(s)}
|
||||||
|
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md">
|
||||||
|
+ {s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gelişmiş daraltma (isteğe bağlı) */}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setGelismis(!gelismis)} className="text-[11px] font-bold text-slate-500 hover:text-slate-800 flex items-center gap-1">
|
||||||
|
{gelismis ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
Daha da daralt (isteğe bağlı) — varsayılan: tüm Türkiye
|
||||||
|
</button>
|
||||||
|
{gelismis && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<input value={daralt.sehir} onChange={(e) => 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" />
|
||||||
|
<input value={daralt.alimTuru} onChange={(e) => 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 && (
|
{favoriteKurumlar?.length > 0 && (
|
||||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
<label className="col-span-2 flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||||
<input type="checkbox" checked={kriter.favoriKurumlar}
|
<input type="checkbox" checked={daralt.favoriKurumlar}
|
||||||
onChange={(e) => setKriter({ ...kriter, favoriKurumlar: e.target.checked })}
|
onChange={(e) => setDaralt({ ...daralt, favoriKurumlar: e.target.checked })}
|
||||||
className="w-3.5 h-3.5 accent-emerald-600 rounded" />
|
className="w-3.5 h-3.5 accent-emerald-600 rounded" />
|
||||||
Yalnızca favori kurumlarım ({favoriteKurumlar.length})
|
Yalnızca favori kurumlarım ({favoriteKurumlar.length})
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button onClick={kaydet} disabled={busy}
|
<button onClick={kaydet} disabled={busy || !konular.length}
|
||||||
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">
|
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" />}
|
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||||
Bildirim Kriteri Ekle
|
Bildirimi Kaydet
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -182,14 +257,12 @@ export const NotifySubscriptions = () => {
|
|||||||
<p className="text-[11px] text-red-800">{error}</p>
|
<p className="text-[11px] text-red-800">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex gap-2">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3 flex gap-2">
|
||||||
<Info className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
|
<Info className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-blue-900 leading-relaxed">
|
<p className="text-[11px] text-slate-600 leading-relaxed">
|
||||||
İlk kriter eklendiğinde mevcut ihaleler <strong>bildirilmez</strong> — yalnızca temel alınır.
|
İlk kayıtta mevcut ihaleler <strong>bildirilmez</strong> — yalnızca temel alınır, sonrasında çıkan
|
||||||
Bundan sonra çıkan yeni ihaleler gönderilir, böylece ilk gün yüzlerce eski ilanla dolmaz.
|
yeni ihaleler gönderilir. Aynı ihale iki kez gönderilmez; sonuçlanmış arşiv kayıtları bildirime girmez.
|
||||||
Sonuçlanmış arşiv kayıtları bildirime girmez.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user