Compare commits
24 Commits
v1.0-admin
...
customer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5172cbdc5 | ||
|
|
edbf8cb1e2 | ||
|
|
0f5d18dac7 | ||
|
|
a39a98b6dc | ||
|
|
1719062b47 | ||
|
|
f94f7c6314 | ||
|
|
6264ebf786 | ||
|
|
25234b70ee | ||
|
|
929d5ab3a1 | ||
|
|
89a176700d | ||
|
|
aa6707b8bc | ||
|
|
c232f0cb79 | ||
|
|
11197c9db1 | ||
|
|
4c82e96f5a | ||
|
|
a1d2c34f36 | ||
|
|
bf3fcd4210 | ||
|
|
8c00d4ea6f | ||
|
|
b2a963a901 | ||
|
|
6b7ac42d1d | ||
|
|
9939ba9fdb | ||
|
|
ea3138ac64 | ||
|
|
a67dd2cc8c | ||
|
|
df90477bef | ||
|
|
60073b97c9 |
@@ -1,4 +1,4 @@
|
||||
APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653
|
||||
APP_ENCRYPTION_SECRET=32 Zeichen
|
||||
|
||||
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
|
||||
DATABASE_URL=file:./leadflow.db
|
||||
@@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY=
|
||||
APIFY_API_KEY=
|
||||
VAYNE_API_KEY=
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
142
app/api/ai-search/route.ts
Normal file
142
app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
|
||||
Die Suchanfragen die du erzeugst werden direkt an die Google Maps Places API übergeben. Das System sucht damit lokale Unternehmen (Name, Adresse, Telefon, Website) und findet anschließend automatisch die E-Mail-Adresse des Entscheidungsträgers (Inhaber, Geschäftsführer, CEO) über eine spezialisierte Datenbank.
|
||||
|
||||
Das bedeutet: Deine Queries müssen so formuliert sein, wie jemand einen Handwerker oder Dienstleister bei Google Maps suchen würde — kurz, präzise, branchenbezogen. Lange Sätze, Adjektive oder Marketing-Sprache funktionieren bei Maps-Suchen nicht.
|
||||
|
||||
## Deine Aufgabe
|
||||
|
||||
Analysiere die Beschreibung des Nutzers und erstelle 2–4 Google-Maps-optimierte Suchanfragen, die zusammen die gewünschte Zielgruppe möglichst vollständig abdecken.
|
||||
|
||||
## Feldmuster
|
||||
|
||||
- **query**: Die Branche oder Tätigkeit — so kurz wie möglich, auf Deutsch, wie ein Mensch bei Google Maps tippt. Keine Adjektive wie "klein" oder "professionell". Keine Firmennamen. Keine URLs.
|
||||
- **region**: Bundesland, Stadt oder geografisches Gebiet. Wenn der Nutzer eine große Region nennt (z.B. "Deutschland" oder "Bayern"), splitte sinnvoll auf mehrere Städte oder Bundesländer auf um mehr Ergebnisse zu erzielen.
|
||||
|
||||
## Priorisierung
|
||||
|
||||
Wähle immer den geläufigsten, meistgesuchten Begriff für eine Branche — nicht Nischenbegriffe oder Synonyme. Beispiele:
|
||||
- "Dachdecker" vor "Spengler" oder "Klempner"
|
||||
- "Elektriker" vor "Elektroinstallateur"
|
||||
- "Steuerberater" vor "Steuerkanzlei"
|
||||
- "Solaranlage" vor "Photovoltaik Fachbetrieb"
|
||||
|
||||
Wenn der Nutzer mehrere Branchen nennt, priorisiere die volumenstärkste zuerst.
|
||||
|
||||
## Splitting-Strategie
|
||||
|
||||
Nutze mehrere Queries wenn:
|
||||
- Die Region zu groß ist für eine Suche (Deutschland → München, Hamburg, Berlin, Köln)
|
||||
- Der Nutzer explizit mehrere verschiedene Branchen nennt (dann je eine Query pro Branche)
|
||||
- Der Nutzer breite Abdeckung möchte
|
||||
|
||||
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
|
||||
|
||||
## Beispiele
|
||||
|
||||
Eingabe: "Dachdecker in Bayern"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Dachdecker", "region": "München" },
|
||||
{ "query": "Dachdecker", "region": "Nürnberg" }
|
||||
]
|
||||
|
||||
Eingabe: "Steuerberater in ganz Deutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Steuerberater", "region": "Bayern" },
|
||||
{ "query": "Steuerberater", "region": "NRW" },
|
||||
{ "query": "Steuerberater", "region": "Hamburg" },
|
||||
{ "query": "Steuerberater", "region": "Berlin" }
|
||||
]
|
||||
|
||||
Eingabe: "Solaranlagen Installateure und Elektriker in Stuttgart"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Solaranlage", "region": "Stuttgart" },
|
||||
{ "query": "Elektriker", "region": "Stuttgart" }
|
||||
]
|
||||
|
||||
Eingabe: "Metallbaubetriebe in Süddeutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Metallbau", "region": "Bayern" },
|
||||
{ "query": "Metallbau", "region": "Baden-Württemberg" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
|
||||
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { description } = await req.json() as { description: string };
|
||||
|
||||
if (!description?.trim()) {
|
||||
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://onvyaleads.app",
|
||||
"X-Title": "OnyvaLeads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.4,
|
||||
max_tokens: 512,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: description.trim() },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error("[ai-search] OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
let queries: Array<{ query: string; region: string; count: number }>;
|
||||
try {
|
||||
queries = JSON.parse(raw);
|
||||
} catch {
|
||||
const match = raw.match(/\[[\s\S]*\]/);
|
||||
if (!match) throw new Error("Kein JSON in Antwort");
|
||||
queries = JSON.parse(match[0]);
|
||||
}
|
||||
|
||||
queries = queries
|
||||
.filter(q => typeof q.query === "string" && q.query.trim())
|
||||
.slice(0, 4)
|
||||
.map(q => ({
|
||||
query: q.query.trim(),
|
||||
region: (q.region ?? "").trim(),
|
||||
count: 50,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ queries });
|
||||
} catch (err) {
|
||||
console.error("[ai-search] error:", err);
|
||||
return NextResponse.json({ error: "Fehler bei der KI-Anfrage" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,21 @@ export async function GET(
|
||||
});
|
||||
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
|
||||
// Find which domains already existed in vault before this job
|
||||
const resultDomains = job.results
|
||||
.map(r => r.domain)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
const preExistingDomains = new Set(
|
||||
(await prisma.lead.findMany({
|
||||
where: {
|
||||
domain: { in: resultDomains },
|
||||
sourceJobId: { not: id },
|
||||
},
|
||||
select: { domain: true },
|
||||
})).map(l => l.domain).filter((d): d is string => !!d)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
@@ -49,6 +64,7 @@ export async function GET(
|
||||
address,
|
||||
phone,
|
||||
createdAt: r.createdAt,
|
||||
isNew: !r.domain || !preExistingDomains.has(r.domain),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -75,7 +75,11 @@ async function runMapsEnrich(
|
||||
});
|
||||
|
||||
// 2. Store raw Google Maps results + immediately sink to LeadVault
|
||||
for (const place of places) {
|
||||
// Skip places without a domain — not actionable as leads
|
||||
const placesWithDomain = places.filter(p => !!p.domain);
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: placesWithDomain.length } });
|
||||
|
||||
for (const place of placesWithDomain) {
|
||||
await prisma.leadResult.create({
|
||||
data: {
|
||||
jobId,
|
||||
@@ -94,7 +98,7 @@ async function runMapsEnrich(
|
||||
// Sink Google Maps results to vault immediately (before enrichment)
|
||||
// so they're available even if Anymailfinder fails
|
||||
await sinkLeadsToVault(
|
||||
places.map(p => ({
|
||||
placesWithDomain.map(p => ({
|
||||
domain: p.domain,
|
||||
companyName: p.name || null,
|
||||
phone: p.phone,
|
||||
@@ -108,71 +112,85 @@ async function runMapsEnrich(
|
||||
);
|
||||
|
||||
// 3. Optionally enrich with Anymailfinder
|
||||
if (params.enrichEmails && places.length > 0) {
|
||||
if (params.enrichEmails && placesWithDomain.length > 0) {
|
||||
const anymailKey = await getApiKey("anymailfinder");
|
||||
if (!anymailKey) throw new Error("Anymailfinder API-Key fehlt — bitte in den Einstellungen eintragen");
|
||||
const domains = places.filter(p => p.domain).map(p => p.domain!);
|
||||
|
||||
// Map domain → placeId for updating results
|
||||
const domainToResultId = new Map<string, string>();
|
||||
const existingResults = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
select: { id: true, domain: true },
|
||||
});
|
||||
for (const r of existingResults) {
|
||||
if (r.domain) domainToResultId.set(r.domain, r.id);
|
||||
if (!anymailKey) {
|
||||
// No key configured — complete with Maps-only results (no emails)
|
||||
await prisma.job.update({ where: { id: jobId }, data: { status: "complete", totalLeads: placesWithDomain.length } });
|
||||
return;
|
||||
}
|
||||
|
||||
let emailsFound = 0;
|
||||
const enrichResults = await bulkSearchDomains(
|
||||
domains,
|
||||
params.categories,
|
||||
anymailKey,
|
||||
async (_completed, total) => {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||
try {
|
||||
const domains = placesWithDomain.map(p => p.domain!);
|
||||
|
||||
// Map domain → leadResult id for updating
|
||||
const domainToResultId = new Map<string, string>();
|
||||
const existingResults = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
select: { id: true, domain: true },
|
||||
});
|
||||
for (const r of existingResults) {
|
||||
if (r.domain) domainToResultId.set(r.domain, r.id);
|
||||
}
|
||||
);
|
||||
|
||||
for (const result of enrichResults) {
|
||||
const hasEmail = !!result.email;
|
||||
if (hasEmail) emailsFound++;
|
||||
let emailsFound = 0;
|
||||
const enrichResults = await bulkSearchDomains(
|
||||
domains,
|
||||
params.categories,
|
||||
anymailKey,
|
||||
async (_completed, total) => {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||
}
|
||||
);
|
||||
|
||||
const resultId = domainToResultId.get(result.domain || "");
|
||||
if (!resultId) continue;
|
||||
for (const result of enrichResults) {
|
||||
const hasEmail = !!result.email;
|
||||
if (hasEmail) emailsFound++;
|
||||
|
||||
await prisma.leadResult.update({
|
||||
where: { id: resultId },
|
||||
data: {
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
},
|
||||
const resultId = domainToResultId.get(result.domain || "");
|
||||
if (!resultId) continue;
|
||||
|
||||
await prisma.leadResult.update({
|
||||
where: { id: resultId },
|
||||
data: {
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.job.update({ where: { id: jobId }, data: { emailsFound } });
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: placesWithDomain.length },
|
||||
});
|
||||
|
||||
await prisma.job.update({ where: { id: jobId }, data: { emailsFound } });
|
||||
// Update vault entries with enrichment results
|
||||
await sinkLeadsToVault(
|
||||
enrichResults
|
||||
.filter(r => r.email)
|
||||
.map(r => ({
|
||||
domain: r.domain,
|
||||
contactName: r.person_full_name || null,
|
||||
contactTitle: r.person_job_title || null,
|
||||
email: r.email || null,
|
||||
linkedinUrl: r.person_linkedin_url || null,
|
||||
})),
|
||||
"maps",
|
||||
params.queries.join(", "),
|
||||
jobId,
|
||||
);
|
||||
} catch (enrichErr) {
|
||||
// Anymailfinder failed (e.g. 402 quota) — complete with Maps-only results
|
||||
console.warn(`[maps-enrich] Anymailfinder failed for job ${jobId}:`, enrichErr);
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", totalLeads: placesWithDomain.length },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: places.length },
|
||||
});
|
||||
|
||||
// Update vault entries with enrichment results
|
||||
await sinkLeadsToVault(
|
||||
enrichResults
|
||||
.filter(r => r.email)
|
||||
.map(r => ({
|
||||
domain: r.domain,
|
||||
contactName: r.person_full_name || null,
|
||||
contactTitle: r.person_job_title || null,
|
||||
email: r.email || null,
|
||||
linkedinUrl: r.person_linkedin_url || null,
|
||||
})),
|
||||
"maps",
|
||||
params.queries.join(", "),
|
||||
jobId,
|
||||
);
|
||||
} else {
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
|
||||
@@ -97,68 +97,109 @@ async function runSerpEnrich(
|
||||
data: { totalLeads: domains.length },
|
||||
});
|
||||
|
||||
// 7. Enrich with Anymailfinder Bulk API
|
||||
const enrichResults = await bulkSearchDomains(
|
||||
domains,
|
||||
params.categories,
|
||||
anymailKey,
|
||||
async (_completed, total) => {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||
}
|
||||
);
|
||||
|
||||
// 8. Store results
|
||||
let emailsFound = 0;
|
||||
for (const result of enrichResults) {
|
||||
const serpData = serpMap.get(result.domain || "");
|
||||
const hasEmail = !!result.valid_email;
|
||||
if (hasEmail) emailsFound++;
|
||||
|
||||
// 7. Store raw SERP results first (so we have leads even if enrichment fails)
|
||||
for (const r of filteredResults) {
|
||||
await prisma.leadResult.create({
|
||||
data: {
|
||||
jobId,
|
||||
companyName: serpData?.title || null,
|
||||
domain: result.domain || null,
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
source: JSON.stringify({
|
||||
url: serpData?.url,
|
||||
description: serpData?.description,
|
||||
position: serpData?.position,
|
||||
email_status: result.email_status,
|
||||
}),
|
||||
companyName: r.title || null,
|
||||
domain: r.domain || null,
|
||||
source: JSON.stringify({ url: r.url, description: r.description, position: r.position }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
|
||||
});
|
||||
|
||||
// Sync to LeadVault
|
||||
// 8. Sink raw results to vault immediately (no contact info yet)
|
||||
await sinkLeadsToVault(
|
||||
enrichResults.map(r => {
|
||||
const serpData = serpMap.get(r.domain || "");
|
||||
return {
|
||||
domain: r.domain || null,
|
||||
companyName: serpData?.title || null,
|
||||
contactName: r.person_full_name || null,
|
||||
contactTitle: r.person_job_title || null,
|
||||
email: r.email || null,
|
||||
linkedinUrl: r.person_linkedin_url || null,
|
||||
serpTitle: serpData?.title || null,
|
||||
serpSnippet: serpData?.description || null,
|
||||
serpRank: serpData?.position ?? null,
|
||||
serpUrl: serpData?.url || null,
|
||||
};
|
||||
}),
|
||||
filteredResults.map(r => ({
|
||||
domain: r.domain || null,
|
||||
companyName: r.title || null,
|
||||
serpTitle: r.title || null,
|
||||
serpSnippet: r.description || null,
|
||||
serpRank: r.position ?? null,
|
||||
serpUrl: r.url || null,
|
||||
})),
|
||||
"serp",
|
||||
params.query,
|
||||
jobId,
|
||||
);
|
||||
|
||||
// 9. Enrich with Anymailfinder (best-effort — failure still completes the job)
|
||||
try {
|
||||
const enrichResults = await bulkSearchDomains(
|
||||
domains,
|
||||
params.categories,
|
||||
anymailKey,
|
||||
async (_completed, total) => {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||
}
|
||||
);
|
||||
|
||||
// Build domain → leadResult id map for updating
|
||||
const domainToResultId = new Map<string, string>();
|
||||
const storedResults = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
select: { id: true, domain: true },
|
||||
});
|
||||
for (const r of storedResults) {
|
||||
if (r.domain) domainToResultId.set(r.domain, r.id);
|
||||
}
|
||||
|
||||
let emailsFound = 0;
|
||||
for (const result of enrichResults) {
|
||||
const hasEmail = !!result.valid_email;
|
||||
if (hasEmail) emailsFound++;
|
||||
|
||||
const resultId = domainToResultId.get(result.domain || "");
|
||||
if (resultId) {
|
||||
await prisma.leadResult.update({
|
||||
where: { id: resultId },
|
||||
data: {
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: filteredResults.length },
|
||||
});
|
||||
|
||||
// Update vault entries with contact info
|
||||
await sinkLeadsToVault(
|
||||
enrichResults
|
||||
.filter(r => r.email)
|
||||
.map(r => {
|
||||
const serpData = serpMap.get(r.domain || "");
|
||||
return {
|
||||
domain: r.domain || null,
|
||||
companyName: serpData?.title || null,
|
||||
contactName: r.person_full_name || null,
|
||||
contactTitle: r.person_job_title || null,
|
||||
email: r.email || null,
|
||||
linkedinUrl: r.person_linkedin_url || null,
|
||||
serpTitle: serpData?.title || null,
|
||||
serpSnippet: serpData?.description || null,
|
||||
serpRank: serpData?.position ?? null,
|
||||
serpUrl: serpData?.url || null,
|
||||
};
|
||||
}),
|
||||
"serp",
|
||||
params.query,
|
||||
jobId,
|
||||
);
|
||||
} catch (enrichErr) {
|
||||
// Anymailfinder failed — complete with SERP-only results (no emails)
|
||||
console.warn(`[serp-enrich] Anymailfinder failed for job ${jobId}:`, enrichErr);
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", totalLeads: filteredResults.length },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.job.update({
|
||||
|
||||
36
app/api/leads/delete-from-results/route.ts
Normal file
36
app/api/leads/delete-from-results/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Deletes vault leads that were created from the given job result IDs.
|
||||
// Looks up domain from each LeadResult and removes matching Lead records.
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { resultIds } = await req.json() as { resultIds: string[] };
|
||||
|
||||
if (!resultIds?.length) {
|
||||
return NextResponse.json({ error: "No result IDs provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get domains from the job results
|
||||
const results = await prisma.leadResult.findMany({
|
||||
where: { id: { in: resultIds } },
|
||||
select: { id: true, domain: true, email: true },
|
||||
});
|
||||
|
||||
const domains = results.map(r => r.domain).filter((d): d is string => !!d);
|
||||
|
||||
// Delete vault leads with matching domains
|
||||
let deleted = 0;
|
||||
if (domains.length > 0) {
|
||||
const { count } = await prisma.lead.deleteMany({
|
||||
where: { domain: { in: domains } },
|
||||
});
|
||||
deleted = count;
|
||||
}
|
||||
|
||||
return NextResponse.json({ deleted });
|
||||
} catch (err) {
|
||||
console.error("POST /api/leads/delete-from-results error:", err);
|
||||
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [total, newLeads, contacted, withEmail] = await Promise.all([
|
||||
const [total, contacted, withEmail] = await Promise.all([
|
||||
prisma.lead.count(),
|
||||
prisma.lead.count({ where: { status: "new" } }),
|
||||
prisma.lead.count({ where: { status: { in: ["contacted", "in_progress"] } } }),
|
||||
prisma.lead.count({ where: { tags: { contains: "kontaktiert" } } }),
|
||||
prisma.lead.count({ where: { email: { not: null } } }),
|
||||
]);
|
||||
const newLeads = total - contacted;
|
||||
|
||||
// Daily counts for last 7 days
|
||||
const sevenDaysAgo = new Date();
|
||||
|
||||
40
app/api/search/route.ts
Normal file
40
app/api/search/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { query: string; region: string; count: number };
|
||||
const { query, region, count } = body;
|
||||
|
||||
if (!query || typeof query !== "string") {
|
||||
return NextResponse.json({ error: "Suchbegriff fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const searchQuery = region ? `${query} ${region}` : query;
|
||||
const base = req.nextUrl.origin;
|
||||
|
||||
// ── 1. Maps job (always, max 60) ──────────────────────────────────────────
|
||||
const mapsRes = await fetch(`${base}/api/jobs/maps-enrich`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
queries: [searchQuery],
|
||||
maxResultsPerQuery: Math.min(count, 60),
|
||||
languageCode: "de",
|
||||
categories: ["ceo"],
|
||||
enrichEmails: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!mapsRes.ok) {
|
||||
const err = await mapsRes.json() as { error?: string };
|
||||
return NextResponse.json({ error: err.error || "Suche konnte nicht gestartet werden" }, { status: 500 });
|
||||
}
|
||||
|
||||
const { jobId } = await mapsRes.json() as { jobId: string };
|
||||
|
||||
return NextResponse.json({ jobId });
|
||||
} catch (err) {
|
||||
console.error("POST /api/search error:", err);
|
||||
return NextResponse.json({ error: "Suche konnte nicht gestartet werden" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
59
app/api/search/supplement/route.ts
Normal file
59
app/api/search/supplement/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getApiKey } from "@/lib/utils/apiKey";
|
||||
import { generateSupplementQuery } from "@/lib/services/openai";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
query: string;
|
||||
region: string;
|
||||
targetCount: number;
|
||||
foundCount: number;
|
||||
};
|
||||
const { query, region, targetCount, foundCount } = body;
|
||||
|
||||
const base = req.nextUrl.origin;
|
||||
const deficit = targetCount - foundCount;
|
||||
|
||||
// 1. Try to generate an optimized query via GPT-4.1
|
||||
let optimizedQuery = region ? `${query} ${region}` : query;
|
||||
let usedAI = false;
|
||||
|
||||
const openaiKey = await getApiKey("openai");
|
||||
if (openaiKey) {
|
||||
const aiQuery = await generateSupplementQuery(query, region, foundCount, targetCount, openaiKey);
|
||||
if (aiQuery) {
|
||||
optimizedQuery = aiQuery;
|
||||
usedAI = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Start SERP job with the (possibly optimized) query
|
||||
const maxPages = Math.min(Math.max(1, Math.ceil(deficit / 10)), 3);
|
||||
|
||||
const serpRes = await fetch(`${base}/api/jobs/serp-enrich`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: optimizedQuery,
|
||||
maxPages,
|
||||
countryCode: "de",
|
||||
languageCode: "de",
|
||||
filterSocial: true,
|
||||
categories: ["ceo"],
|
||||
enrichEmails: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!serpRes.ok) {
|
||||
const err = await serpRes.json() as { error?: string };
|
||||
return NextResponse.json({ error: err.error || "SERP job konnte nicht gestartet werden" }, { status: 500 });
|
||||
}
|
||||
|
||||
const { jobId } = await serpRes.json() as { jobId: string };
|
||||
return NextResponse.json({ jobId, optimizedQuery, usedAI });
|
||||
} catch (err) {
|
||||
console.error("POST /api/search/supplement error:", err);
|
||||
return NextResponse.json({ error: "Supplement-Suche fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { TopBar } from "@/components/layout/TopBar";
|
||||
import { Topbar } from "@/components/layout/TopBar";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OnyvaLeads — Lead Generation Platform",
|
||||
description: "Unified lead generation and email enrichment platform",
|
||||
title: "OnyvaLeads",
|
||||
description: "Leads finden und verwalten",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<html lang="de" className="dark">
|
||||
<body className="antialiased">
|
||||
<div className="flex flex-col min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" theme="dark" />
|
||||
</body>
|
||||
|
||||
916
app/leadspeicher/page.tsx
Normal file
916
app/leadspeicher/page.tsx
Normal file
@@ -0,0 +1,916 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink,
|
||||
Mail, Star, Tag, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
CheckSquare, Square, Download, Phone, Copy, MapPin, Building2, Globe
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
domain: string | null;
|
||||
companyName: string | null;
|
||||
contactName: string | null;
|
||||
contactTitle: string | null;
|
||||
email: string | null;
|
||||
linkedinUrl: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
sourceTab: string;
|
||||
sourceTerm: string | null;
|
||||
sourceJobId: string | null;
|
||||
serpTitle: string | null;
|
||||
serpSnippet: string | null;
|
||||
serpRank: number | null;
|
||||
serpUrl: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
country: string | null;
|
||||
headcount: string | null;
|
||||
industry: string | null;
|
||||
description: string | null;
|
||||
capturedAt: string;
|
||||
contactedAt: string | null;
|
||||
events?: Array<{ id: string; event: string; at: string }>;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
new: number;
|
||||
contacted: number;
|
||||
withEmail: number;
|
||||
dailyCounts: Array<{ date: string; count: number }>;
|
||||
}
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
new: { label: "Neu", color: "text-blue-300", bg: "bg-blue-500/20 border-blue-500/30" },
|
||||
in_progress: { label: "In Bearbeitung",color: "text-purple-300", bg: "bg-purple-500/20 border-purple-500/30" },
|
||||
contacted: { label: "Kontaktiert", color: "text-teal-300", bg: "bg-teal-500/20 border-teal-500/30" },
|
||||
qualified: { label: "Qualifiziert", color: "text-green-300", bg: "bg-green-500/20 border-green-500/30" },
|
||||
not_relevant: { label: "Nicht relevant",color: "text-gray-400", bg: "bg-gray-500/20 border-gray-500/30" },
|
||||
converted: { label: "Konvertiert", color: "text-amber-300", bg: "bg-amber-500/20 border-amber-500/30" },
|
||||
};
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { label: string; icon: string }> = {
|
||||
airscale: { label: "AirScale", icon: "🏢" },
|
||||
linkedin: { label: "LinkedIn", icon: "💼" },
|
||||
serp: { label: "SERP", icon: "🔍" },
|
||||
"quick-serp": { label: "Quick SERP", icon: "⚡" },
|
||||
maps: { label: "Maps", icon: "📍" },
|
||||
};
|
||||
|
||||
const TAG_PRESETS = [
|
||||
"solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt",
|
||||
"kaltakquise", "follow-up", "interessiert", "angebot-gesendet",
|
||||
];
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < tag.length; i++) hash = tag.charCodeAt(i) + ((hash << 5) - hash);
|
||||
const colors = [
|
||||
"bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
"bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
"bg-green-500/20 text-green-300 border-green-500/30",
|
||||
"bg-amber-500/20 text-amber-300 border-amber-500/30",
|
||||
"bg-pink-500/20 text-pink-300 border-pink-500/30",
|
||||
"bg-teal-500/20 text-teal-300 border-teal-500/30",
|
||||
"bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
// ─── Sparkline ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
if (!data.length) return null;
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 60, h = 24;
|
||||
const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * h}`).join(" ");
|
||||
return (
|
||||
<svg width={w} height={h} className="opacity-70">
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
lead: Lead;
|
||||
onClose: () => void;
|
||||
onUpdate: (updated: Partial<Lead>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} kopiert`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative z-50 w-full max-w-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden"
|
||||
style={{ animation: "slideIn 200ms ease-out" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-b border-[#1e1e2e] p-4 flex items-start gap-3 flex-shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline flex items-center gap-1 mt-0.5">
|
||||
<Globe className="w-3 h-3" />{lead.domain}
|
||||
</a>
|
||||
)}
|
||||
{lead.industry && <p className="text-xs text-purple-400 mt-0.5">{lead.industry}</p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
|
||||
{/* Description */}
|
||||
{lead.description && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</p>
|
||||
<p className="text-xs text-gray-300 leading-relaxed">{lead.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
{(lead.contactName || lead.email || lead.phone || lead.address || lead.linkedinUrl) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Kontakt</p>
|
||||
<div className="space-y-2">
|
||||
{lead.contactName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<span className="text-sm text-white">{lead.contactName}
|
||||
{lead.contactTitle && <span className="text-gray-500 ml-1 text-xs">· {lead.contactTitle}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<span className="text-xs font-mono text-green-400 truncate flex-1">{lead.email}</span>
|
||||
<button onClick={() => copy(lead.email!, "E-Mail")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<a href={`tel:${lead.phone}`} className="text-xs text-gray-300 hover:text-white">{lead.phone}</a>
|
||||
<button onClick={() => copy(lead.phone!, "Telefon")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{lead.address && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-gray-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-300 flex-1">{lead.address}</span>
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(lead.address)}`}
|
||||
target="_blank" rel="noreferrer"
|
||||
className="text-gray-600 hover:text-green-400 flex-shrink-0"
|
||||
title="In Google Maps öffnen"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{lead.linkedinUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<a href={lead.linkedinUrl} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline truncate">
|
||||
LinkedIn Profil
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
{(lead.headcount || lead.country) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Unternehmen</p>
|
||||
<div className="flex gap-3 text-xs text-gray-400">
|
||||
{lead.headcount && <span>👥 {lead.headcount} Mitarbeiter</span>}
|
||||
{lead.country && <span>🌍 {lead.country}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Quelle</p>
|
||||
<div className="space-y-1 text-xs text-gray-400">
|
||||
<p>{src?.icon} {src?.label || lead.sourceTab}</p>
|
||||
{lead.sourceTerm && (
|
||||
<p>Suche: <span className="text-gray-300">"{lead.sourceTerm}"</span></p>
|
||||
)}
|
||||
<p>Erfasst: <span className="text-gray-300">{new Date(lead.capturedAt).toLocaleString("de-DE")}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags (read-only) */}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className={`px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="p-4 border-t border-[#1e1e2e] flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { onDelete(lead.id); onClose(); }}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 text-sm transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Lead löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LeadVaultPage() {
|
||||
// Stats
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
// Quick SERP
|
||||
const [serpOpen, setSerpOpen] = useState(false);
|
||||
const [serpQuery, setSerpQuery] = useState("");
|
||||
const [serpCount, setSerpCount] = useState("25");
|
||||
const serpCountry = "de";
|
||||
const serpLanguage = "de";
|
||||
const [serpFilter, setSerpFilter] = useState(true);
|
||||
const [serpRunning, setSerpRunning] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string[]>([]);
|
||||
const [filterSource, setFilterSource] = useState<string[]>([]);
|
||||
const [filterHasEmail, setFilterHasEmail] = useState("");
|
||||
const [filterContacted, setFilterContacted] = useState(false);
|
||||
const [filterFavorite, setFilterFavorite] = useState(false);
|
||||
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
|
||||
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
|
||||
|
||||
// Table
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(50);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [sortBy, setSortBy] = useState("capturedAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Side panel
|
||||
const [panelLead, setPanelLead] = useState<Lead | null>(null);
|
||||
|
||||
// Export dropdown
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
// Bulk
|
||||
const [bulkTag, setBulkTag] = useState("");
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
const res = await fetch("/api/leads/stats");
|
||||
if (res.ok) setStats(await res.json() as Stats);
|
||||
}, []);
|
||||
|
||||
const loadLeads = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page), perPage: String(perPage),
|
||||
search: debouncedSearch, sortBy, sortDir,
|
||||
});
|
||||
filterStatus.forEach(s => params.append("status", s));
|
||||
filterSource.forEach(s => params.append("sourceTab", s));
|
||||
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
||||
if (filterContacted) params.set("contacted", "yes");
|
||||
if (filterFavorite) params.set("favorite", "yes");
|
||||
filterSearchTerms.forEach(t => params.append("searchTerm", t));
|
||||
|
||||
const res = await fetch(`/api/leads?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { leads: Lead[]; total: number; pages: number };
|
||||
setLeads(data.leads);
|
||||
setTotal(data.total);
|
||||
setPages(data.pages);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterHasEmail, filterContacted, filterFavorite, filterSearchTerms]);
|
||||
|
||||
const loadSearchTerms = useCallback(async () => {
|
||||
const res = await fetch("/api/leads/search-terms");
|
||||
if (res.ok) setAvailableSearchTerms(await res.json() as string[]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]);
|
||||
useEffect(() => { loadLeads(); }, [loadLeads]);
|
||||
useEffect(() => { loadSearchTerms(); }, [loadSearchTerms]);
|
||||
|
||||
function toggleSort(field: string) {
|
||||
if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortBy(field); setSortDir("desc"); }
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function toggleFilter(arr: string[], setArr: (v: string[]) => void, val: string) {
|
||||
setArr(arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val]);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
||||
setFilterHasEmail(""); setFilterContacted(false); setFilterFavorite(false);
|
||||
setFilterSearchTerms([]);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function updateLead(id: string, data: Partial<Lead>) {
|
||||
// Optimistic update
|
||||
setLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
|
||||
if (panelLead?.id === id) setPanelLead(prev => prev ? { ...prev, ...data } : prev);
|
||||
|
||||
const res = await fetch(`/api/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error("Update fehlgeschlagen");
|
||||
loadLeads(); // Revert
|
||||
} else {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLead(id: string) {
|
||||
if (!confirm("Lead löschen?")) return;
|
||||
await fetch(`/api/leads/${id}`, { method: "DELETE" });
|
||||
setLeads(prev => prev.filter(l => l.id !== id));
|
||||
setTotal(prev => prev - 1);
|
||||
if (panelLead?.id === id) setPanelLead(null);
|
||||
loadStats();
|
||||
toast.success("Lead gelöscht");
|
||||
}
|
||||
|
||||
async function runQuickSerp() {
|
||||
if (!serpQuery.trim()) return toast.error("Suchbegriff eingeben");
|
||||
setSerpRunning(true);
|
||||
try {
|
||||
const res = await fetch("/api/leads/quick-serp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: serpQuery, count: Number(serpCount), country: serpCountry, language: serpLanguage, filterSocial: serpFilter }),
|
||||
});
|
||||
const data = await res.json() as { added?: number; updated?: number; skipped?: number; error?: string };
|
||||
if (!res.ok) throw new Error(data.error || "Fehler");
|
||||
toast.success(`${data.added} Leads hinzugefügt, ${data.skipped} übersprungen`);
|
||||
loadLeads(); loadStats(); loadSearchTerms();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Fehler");
|
||||
} finally {
|
||||
setSerpRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkAction(action: "status" | "priority" | "tag" | "delete", value: string) {
|
||||
if (!selected.size) return;
|
||||
if (action === "delete" && !confirm(`${selected.size} Leads löschen?`)) return;
|
||||
const res = await fetch("/api/leads/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selected), action, value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json() as { updated: number };
|
||||
toast.success(`${d.updated} Leads aktualisiert`);
|
||||
setSelected(new Set());
|
||||
loadLeads(); loadStats(); loadSearchTerms();
|
||||
}
|
||||
}
|
||||
|
||||
function exportFile(format: "csv" | "xlsx", emailOnly = false) {
|
||||
const params = new URLSearchParams({ search: debouncedSearch, sortBy, sortDir, format });
|
||||
filterStatus.forEach(s => params.append("status", s));
|
||||
filterSource.forEach(s => params.append("sourceTab", s));
|
||||
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
||||
if (emailOnly) params.set("emailOnly", "true");
|
||||
window.open(`/api/leads/export?${params}`, "_blank");
|
||||
}
|
||||
|
||||
function SortIcon({ field }: { field: string }) {
|
||||
if (sortBy !== field) return <ArrowUpDown className="w-3 h-3 text-gray-600" />;
|
||||
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 text-purple-400" /> : <ArrowDown className="w-3 h-3 text-purple-400" />;
|
||||
}
|
||||
|
||||
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-full px-[72px] py-[72px]">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>Zentrale Datenbank</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">🗄️ Leadspeicher</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">Alle Leads an einem Ort.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setExportOpen(v => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-purple-300 hover:bg-purple-500/20 text-sm transition-all"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export dropdown — outside overflow-hidden header */}
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
|
||||
<div className="fixed top-20 right-6 z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-2xl p-1 min-w-[230px]">
|
||||
{([
|
||||
["Aktuelle Ansicht", () => exportFile("xlsx")],
|
||||
["Nur mit E-Mail", () => exportFile("xlsx", true)],
|
||||
] as [string, () => void][]).map(([label, fn]) => (
|
||||
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Leads gesamt", value: stats.total, color: "#a78bfa" },
|
||||
{ label: "Neu / Nicht kontaktiert", value: stats.new, color: "#60a5fa" },
|
||||
{ label: "Kontaktiert / In Bearbeitung", value: stats.contacted, color: "#2dd4bf" },
|
||||
{ label: "Mit verifizierter E-Mail", value: stats.withEmail, color: "#34d399" },
|
||||
].map(({ label, value, color }) => (
|
||||
<Card key={label} className="bg-[#111118] border-[#1e1e2e] p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick SERP */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSerpOpen(!serpOpen)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" />⚡ Schnell neue Suche</span>
|
||||
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
{serpOpen && (
|
||||
<div className="px-5 pb-5 border-t border-[#1e1e2e] pt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
placeholder='Suchbegriff, z.B. "Dachdecker Freiburg"'
|
||||
value={serpQuery}
|
||||
onChange={e => setSerpQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && runQuickSerp()}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<Select value={serpCount} onValueChange={v => setSerpCount(v ?? "25")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
{["10", "25", "50", "100"].map(v => (
|
||||
<SelectItem key={v} value={v} className="text-gray-300">{v} Ergebnisse</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input type="checkbox" checked={serpFilter} onChange={e => setSerpFilter(e.target.checked)} className="rounded" />
|
||||
Social-Media / Verzeichnisse herausfiltern
|
||||
</label>
|
||||
<Button
|
||||
onClick={runQuickSerp}
|
||||
disabled={serpRunning || !serpQuery.trim()}
|
||||
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-6"
|
||||
>
|
||||
{serpRunning ? "Suche läuft..." : "🔍 SERP Capture starten"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Domain, Firma, Name, E-Mail suchen..."
|
||||
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded-lg pl-8 pr-3 py-1.5 text-sm text-white outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Has Email toggle */}
|
||||
<div className="flex gap-1 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg p-1">
|
||||
{[["", "Alle"], ["yes", "Mit E-Mail"], ["no", "Ohne E-Mail"]].map(([v, l]) => (
|
||||
<button key={v} onClick={() => { setFilterHasEmail(v); setPage(1); }}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${filterHasEmail === v ? "bg-purple-500/30 text-purple-300" : "text-gray-500 hover:text-gray-300"}`}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Kontaktiert toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterContacted(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterContacted
|
||||
? "bg-teal-500/20 text-teal-300 border-teal-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
>
|
||||
Kontaktiert
|
||||
</button>
|
||||
|
||||
{/* Favoriten toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterFavorite
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
>
|
||||
★ Favoriten
|
||||
</button>
|
||||
|
||||
{/* Clear + count */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-white flex items-center gap-1">
|
||||
<X className="w-3 h-3" /> Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{total.toLocaleString()} Leads</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 bg-purple-500/10 border border-purple-500/20 rounded-xl px-4 py-2.5">
|
||||
<span className="text-sm text-purple-300 font-medium">{selected.size} ausgewählt</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={bulkTag}
|
||||
onChange={e => setBulkTag(e.target.value)}
|
||||
placeholder="Tag hinzufügen..."
|
||||
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1 w-32 outline-none"
|
||||
/>
|
||||
<button onClick={() => { if (bulkTag) { bulkAction("tag", bulkTag); setBulkTag(""); } }}
|
||||
className="text-xs px-2 py-1 rounded bg-[#2e2e3e] text-gray-300 hover:bg-[#3e3e5e]">
|
||||
<Tag className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => bulkAction("delete", "")}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-red-500/20 text-red-300 border border-red-500/30 hover:bg-red-500/30">
|
||||
<Trash2 className="w-3 h-3" /> Löschen
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="text-xs text-gray-500 hover:text-white px-2">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e1e2e]">
|
||||
<th className="px-3 py-2.5 text-left">
|
||||
<button onClick={() => {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(leads.map(l => l.id)));
|
||||
}}>
|
||||
{allSelected
|
||||
? <CheckSquare className="w-4 h-4 text-purple-400" />
|
||||
: <Square className="w-4 h-4 text-gray-600" />}
|
||||
</button>
|
||||
</th>
|
||||
{[
|
||||
["companyName", "Unternehmen"],
|
||||
["contactName", "Kontakt"],
|
||||
["phone", "Telefon"],
|
||||
["email", "E-Mail"],
|
||||
["sourceTerm", "Suchbegriff"],
|
||||
["capturedAt", "Erfasst"],
|
||||
].map(([field, label]) => (
|
||||
<th key={field} className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 whitespace-nowrap">
|
||||
<button onClick={() => toggleSort(field)} className="flex items-center gap-1 hover:text-gray-300">
|
||||
{label} <SortIcon field={field} />
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Tags</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-medium text-gray-500">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !leads.length ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
||||
) : leads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-16 text-center">
|
||||
<Database className="w-8 h-8 text-gray-700 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">
|
||||
{(search || filterSource.length || filterContacted || filterFavorite)
|
||||
? "Keine Leads für diese Filter."
|
||||
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
|
||||
</p>
|
||||
{search && (
|
||||
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : leads.map((lead, i) => {
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const isSelected = selected.has(lead.id);
|
||||
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
const isFavorite = tags.includes("favorit");
|
||||
const isContacted = tags.includes("kontaktiert");
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => setPanelLead(lead)}
|
||||
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
|
||||
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => {
|
||||
setSelected(prev => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(lead.id)) n.delete(lead.id); else n.add(lead.id);
|
||||
return n;
|
||||
});
|
||||
}}>
|
||||
{isSelected
|
||||
? <CheckSquare className="w-4 h-4 text-purple-400" />
|
||||
: <Square className="w-4 h-4 text-gray-600" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[220px]">
|
||||
<p className="text-sm text-white truncate font-medium">{lead.companyName || "–"}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
|
||||
{(lead.description || lead.industry) && (
|
||||
<p className="text-[10px] text-gray-600 truncate mt-0.5" title={lead.description || lead.industry || ""}>
|
||||
{lead.industry ? `${lead.industry}${lead.description ? " · " + lead.description : ""}` : lead.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[160px]">
|
||||
<p className="text-sm text-gray-300 truncate">{lead.contactName || "–"}</p>
|
||||
{lead.contactTitle && <p className="text-xs text-gray-600 truncate">{lead.contactTitle}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[160px]">
|
||||
{lead.phone ? (
|
||||
<a href={`tel:${lead.phone}`} onClick={e => e.stopPropagation()}
|
||||
className="text-xs text-gray-300 hover:text-white whitespace-nowrap">
|
||||
{lead.phone}
|
||||
</a>
|
||||
) : lead.address ? (
|
||||
<p className="text-xs text-gray-500 truncate" title={lead.address}>{lead.address}</p>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">–</span>
|
||||
)}
|
||||
{lead.phone && lead.address && (
|
||||
<p className="text-[11px] text-gray-600 truncate" title={lead.address}>{lead.address}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[200px]" onClick={e => e.stopPropagation()}>
|
||||
{lead.email ? (
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(lead.email!); toast.success("E-Mail kopiert"); }}
|
||||
className="text-xs font-mono text-green-400 hover:text-green-300 truncate block max-w-full"
|
||||
title={lead.email}
|
||||
>
|
||||
{lead.email}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[160px]">
|
||||
{lead.sourceTerm ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleFilter(filterSearchTerms, setFilterSearchTerms, lead.sourceTerm!); setPage(1); }}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-all truncate max-w-full block ${
|
||||
filterSearchTerms.includes(lead.sourceTerm)
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "bg-[#1a1a28] text-gray-400 border-[#2e2e3e] hover:border-amber-500/30 hover:text-amber-300"
|
||||
}`}
|
||||
title={lead.sourceTerm}
|
||||
>
|
||||
🔎 {lead.sourceTerm}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
|
||||
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<span key={tag} className={`text-[10px] px-1.5 py-0.5 rounded-full border ${tagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
{tags.length > 2 && <span className="text-[10px] text-gray-500">+{tags.length - 2}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (isContacted) {
|
||||
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "kontaktiert")) });
|
||||
} else {
|
||||
updateLead(lead.id, { tags: JSON.stringify([...tags, "kontaktiert"]) });
|
||||
}
|
||||
}}
|
||||
title="Kontaktiert"
|
||||
className={isContacted
|
||||
? "p-1 rounded text-teal-400 bg-teal-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all"}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite) {
|
||||
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "favorit")) });
|
||||
} else {
|
||||
updateLead(lead.id, { tags: JSON.stringify([...tags, "favorit"]) });
|
||||
}
|
||||
}}
|
||||
title="Favorit"
|
||||
className={isFavorite
|
||||
? "p-1 rounded text-amber-400 bg-amber-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
title="Website öffnen"
|
||||
className="p-1 rounded text-gray-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => deleteLead(lead.id)}
|
||||
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-all">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#1e1e2e]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Zeilen pro Seite:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={e => { setPerPage(Number(e.target.value)); setPage(1); }}
|
||||
className="bg-[#0d0d18] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
|
||||
>
|
||||
{[25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
|
||||
‹ Zurück
|
||||
</button>
|
||||
{Array.from({ length: Math.min(7, pages) }, (_, i) => {
|
||||
let p = i + 1;
|
||||
if (pages > 7) {
|
||||
if (page <= 4) p = i + 1;
|
||||
else if (page >= pages - 3) p = pages - 6 + i;
|
||||
else p = page - 3 + i;
|
||||
}
|
||||
return (
|
||||
<button key={p} onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 rounded text-xs ${page === p ? "bg-purple-500/30 text-purple-300 border border-purple-500/30" : "text-gray-500 hover:text-gray-300"}`}>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button disabled={page >= pages} onClick={() => setPage(p => p + 1)}
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
|
||||
Weiter ›
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Seite {page} von {pages}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Side Panel */}
|
||||
{panelLead && (
|
||||
<SidePanel
|
||||
lead={panelLead}
|
||||
onClose={() => setPanelLead(null)}
|
||||
onUpdate={updated => {
|
||||
setPanelLead(prev => prev ? { ...prev, ...updated } : prev);
|
||||
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
|
||||
}}
|
||||
onDelete={id => { deleteLead(id); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/airscale");
|
||||
redirect("/suche");
|
||||
}
|
||||
|
||||
506
app/suche/page.tsx
Normal file
506
app/suche/page.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { SearchCard } from "@/components/search/SearchCard";
|
||||
import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
|
||||
import { AiSearchModal } from "@/components/search/AiSearchModal";
|
||||
|
||||
export default function SuchePage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [region, setRegion] = useState("");
|
||||
const [count, setCount] = useState(50);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [leads, setLeads] = useState<LeadResult[]>([]);
|
||||
const [searchDone, setSearchDone] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [onlyNew, setOnlyNew] = useState(false);
|
||||
const [saveOnlyNew, setSaveOnlyNew] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
|
||||
function handleChange(field: "query" | "region" | "count", value: string | number) {
|
||||
if (field === "query") setQuery(value as string);
|
||||
if (field === "region") setRegion(value as string);
|
||||
if (field === "count") setCount(value as number);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!query.trim() || loading) return;
|
||||
setLoading(true);
|
||||
setJobId(null);
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
setSelected(new Set());
|
||||
setOnlyNew(false);
|
||||
// saveOnlyNew intentionally kept — user setting persists across searches
|
||||
try {
|
||||
const res = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: query.trim(), region: region.trim(), count }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error?: string };
|
||||
throw new Error(err.error || "Fehler beim Starten der Suche");
|
||||
}
|
||||
const data = await res.json() as { jobId: string };
|
||||
setJobId(data.jobId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
toast.error(msg);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDone = useCallback((result: LeadResult[], warning?: string) => {
|
||||
setLoading(false);
|
||||
setLeads(result);
|
||||
setSearchDone(true);
|
||||
if (warning) {
|
||||
toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
|
||||
} else {
|
||||
toast.success(`✓ ${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleDelete(ids: string[]) {
|
||||
if (!ids.length || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch("/api/leads/delete-from-results", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ resultIds: ids }),
|
||||
});
|
||||
setLeads(prev => prev.filter(l => !ids.includes(l.id)));
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach(id => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
toast.success(`${ids.length} Lead${ids.length > 1 ? "s" : ""} gelöscht`);
|
||||
} catch {
|
||||
toast.error("Löschen fehlgeschlagen");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveOnlyNew() {
|
||||
const existingIds = leads.filter(l => !l.isNew).map(l => l.id);
|
||||
if (!existingIds.length || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch("/api/leads/delete-from-results", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ resultIds: existingIds }),
|
||||
});
|
||||
setSaveOnlyNew(true);
|
||||
const newCount = leads.filter(l => l.isNew).length;
|
||||
toast.success(`✓ ${newCount} neue Leads behalten, ${existingIds.length} vorhandene aus Leadspeicher entfernt`, { duration: 5000 });
|
||||
} catch {
|
||||
toast.error("Fehler beim Bereinigen");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = useCallback((message: string) => {
|
||||
setLoading(false);
|
||||
setJobId(null);
|
||||
toast.error(`Suche fehlgeschlagen: ${message}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "72px 120px",
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{/* Hero */}
|
||||
<div className="relative rounded-2xl border border-[#1e1e2e] p-6 overflow-hidden mb-6"
|
||||
style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.08) 0%, rgba(139,92,246,0.08) 100%)" }}>
|
||||
<div className="absolute inset-0" style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.04) 0%, transparent 60%)" }} />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: "#3b82f6" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<span>Lead-Suche</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 500, color: "#ffffff", margin: 0, marginBottom: 6 }}>
|
||||
Leads finden
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: "#9ca3af", margin: 0 }}>
|
||||
Suchbegriff eingeben — wir finden passende Unternehmen mit Kontaktdaten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiOpen(true)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 7,
|
||||
padding: "8px 14px", borderRadius: 8,
|
||||
border: "1px solid rgba(139,92,246,0.35)",
|
||||
background: "rgba(139,92,246,0.1)", color: "#a78bfa",
|
||||
fontSize: 13, fontWeight: 500,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
opacity: loading ? 0.5 : 1, whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={e => { if (!loading) { e.currentTarget.style.background = "rgba(139,92,246,0.18)"; e.currentTarget.style.borderColor = "rgba(139,92,246,0.6)"; }}}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "rgba(139,92,246,0.1)"; e.currentTarget.style.borderColor = "rgba(139,92,246,0.35)"; }}
|
||||
>
|
||||
✨ KI-Suche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Card */}
|
||||
<SearchCard
|
||||
query={query}
|
||||
region={region}
|
||||
count={count}
|
||||
loading={loading}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
|
||||
{/* Loading Card */}
|
||||
{loading && jobId && (
|
||||
<LoadingCard
|
||||
jobId={jobId}
|
||||
targetCount={count}
|
||||
query={query}
|
||||
region={region}
|
||||
onDone={handleDone}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Modal */}
|
||||
{aiOpen && (
|
||||
<AiSearchModal
|
||||
onStart={(queries) => {
|
||||
setAiOpen(false);
|
||||
if (!queries.length) return;
|
||||
// Fill first query into the search fields and submit
|
||||
const first = queries[0];
|
||||
setQuery(first.query);
|
||||
setRegion(first.region);
|
||||
setCount(first.count);
|
||||
setLoading(true);
|
||||
setJobId(null);
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
setSelected(new Set());
|
||||
fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: first.query, region: first.region, count: first.count }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((d: { jobId?: string; error?: string }) => {
|
||||
if (d.jobId) setJobId(d.jobId);
|
||||
else throw new Error(d.error);
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(err instanceof Error ? err.message : "Fehler");
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
onClose={() => setAiOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{searchDone && leads.length > 0 && (() => {
|
||||
const newCount = leads.filter(l => l.isNew).length;
|
||||
const visibleLeads = onlyNew ? leads.filter(l => l.isNew) : leads;
|
||||
const allVisibleSelected = visibleLeads.length > 0 && visibleLeads.every(l => selected.has(l.id));
|
||||
const someVisibleSelected = visibleLeads.some(l => selected.has(l.id));
|
||||
const selectedVisible = visibleLeads.filter(l => selected.has(l.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.del-btn:hover { opacity: 1 !important; }
|
||||
.lead-row:hover { background: rgba(255,255,255,0.02); }
|
||||
.lead-row:hover .row-del { opacity: 1 !important; }
|
||||
.filter-pill { transition: background 0.15s, color 0.15s; }
|
||||
.filter-pill:hover { background: rgba(59,130,246,0.15) !important; }
|
||||
.save-new-btn {
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
.save-new-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(59,130,246,0.22), rgba(139,92,246,0.22)) !important;
|
||||
border-color: rgba(99,102,241,0.65) !important;
|
||||
color: #c7d2fe !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99,102,241,0.2);
|
||||
}
|
||||
.save-new-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Select-all checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
ref={el => { if (el) el.indeterminate = someVisibleSelected && !allVisibleSelected; }}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
visibleLeads.forEach(l => next.add(l.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
visibleLeads.forEach(l => next.delete(l.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 14, height: 14 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
|
||||
{selectedVisible.length > 0
|
||||
? `${selectedVisible.length} ausgewählt`
|
||||
: `${visibleLeads.length} Leads`}
|
||||
</span>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
gap: 2,
|
||||
}}>
|
||||
{[
|
||||
{ label: `Alle (${leads.length})`, value: false },
|
||||
{ label: `Nur neue (${newCount})`, value: true },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={String(tab.value)}
|
||||
onClick={() => setOnlyNew(tab.value)}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
background: onlyNew === tab.value ? "#1e1e2e" : "transparent",
|
||||
color: onlyNew === tab.value ? "#ffffff" : "#6b7280",
|
||||
fontWeight: onlyNew === tab.value ? 500 : 400,
|
||||
transition: "background 0.12s, color 0.12s",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{selectedVisible.length > 0 && (
|
||||
<button
|
||||
className="del-btn"
|
||||
onClick={() => handleDelete(selectedVisible.map(l => l.id))}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "rgba(239,68,68,0.12)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 7,
|
||||
padding: "5px 12px",
|
||||
fontSize: 12,
|
||||
color: "#ef4444",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
opacity: deleting ? 0.5 : 0.85,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
{selectedVisible.length} löschen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nur neue speichern — only shown when there are existing leads */}
|
||||
{!saveOnlyNew && leads.some(l => !l.isNew) && (
|
||||
<button
|
||||
className="save-new-btn"
|
||||
onClick={handleSaveOnlyNew}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "linear-gradient(135deg, rgba(59,130,246,0.12), rgba(139,92,246,0.12))",
|
||||
border: "1px solid rgba(99,102,241,0.35)",
|
||||
borderRadius: 7,
|
||||
padding: "5px 12px",
|
||||
fontSize: 12,
|
||||
color: "#a5b4fc",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: deleting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"/>
|
||||
<path d="M9 12h6M12 9v6"/>
|
||||
</svg>
|
||||
Nur neue speichern
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link href="/leadspeicher" style={{ fontSize: 12, color: "#3b82f6", textDecoration: "none" }}>
|
||||
Im Leadspeicher →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid #1e1e2e" }}>
|
||||
<th style={{ width: 40, padding: "8px 16px" }} />
|
||||
{["Unternehmen", "Domain", "Kontakt", "E-Mail"].map(h => (
|
||||
<th key={h} style={{ padding: "8px 16px", textAlign: "left", color: "#6b7280", fontWeight: 500 }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ width: 40, padding: "8px 16px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleLeads.map((lead, i) => {
|
||||
const isSelected = selected.has(lead.id);
|
||||
return (
|
||||
<tr
|
||||
key={lead.id}
|
||||
className="lead-row"
|
||||
style={{
|
||||
borderBottom: i < visibleLeads.length - 1 ? "1px solid #1a1a2a" : "none",
|
||||
background: isSelected ? "rgba(59,130,246,0.06)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "9px 16px", textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={e => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
e.target.checked ? next.add(lead.id) : next.delete(lead.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 13, height: 13 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "9px 16px", color: "#ffffff" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{lead.companyName || "—"}
|
||||
{!lead.isNew && (
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: "#6b7280",
|
||||
background: "#1e1e2e",
|
||||
borderRadius: 4,
|
||||
padding: "1px 6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
vorhanden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "9px 16px", color: "#6b7280" }}>{lead.domain || "—"}</td>
|
||||
<td style={{ padding: "9px 16px", color: "#d1d5db" }}>
|
||||
{lead.contactName
|
||||
? `${lead.contactName}${lead.contactTitle ? ` · ${lead.contactTitle}` : ""}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td style={{ padding: "9px 16px" }}>
|
||||
{lead.email ? (
|
||||
<a href={`mailto:${lead.email}`} style={{ color: "#3b82f6", textDecoration: "none" }}>
|
||||
{lead.email}
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: "#4b5563" }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "9px 12px", textAlign: "center" }}>
|
||||
<button
|
||||
className="row-del"
|
||||
onClick={() => handleDelete([lead.id])}
|
||||
disabled={deleting}
|
||||
title="Lead löschen"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
color: "#6b7280",
|
||||
opacity: 0,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const BREADCRUMBS: Record<string, string> = {
|
||||
"/airscale": "AirScale → E-Mail",
|
||||
"/linkedin": "LinkedIn → E-Mail",
|
||||
"/serp": "SERP → E-Mail",
|
||||
"/maps": "Google Maps → E-Mail",
|
||||
"/leadvault": "🗄️ Leadspeicher",
|
||||
"/results": "Ergebnisse & Verlauf",
|
||||
"/settings": "Einstellungen",
|
||||
};
|
||||
function OnyvaLogo() {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* 5-pointed star SVG */}
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<polygon
|
||||
points="7,1 8.5,5.5 13,5.5 9.5,8.5 10.8,13 7,10.2 3.2,13 4.5,8.5 1,5.5 5.5,5.5"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 500, color: "#ffffff" }}>OnyvaLeads</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
export function Topbar() {
|
||||
const pathname = usePathname();
|
||||
const { activeJobs } = useAppStore();
|
||||
const runningJobs = activeJobs.filter(j => j.status === "running").length;
|
||||
const label = BREADCRUMBS[pathname] || "Dashboard";
|
||||
const [newLeadsCount, setNewLeadsCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
function fetchNewLeads() {
|
||||
fetch("/api/leads/stats")
|
||||
.then((r) => r.json())
|
||||
.then((d: { new?: number }) => setNewLeadsCount(d.new ?? 0))
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchNewLeads();
|
||||
const t = setInterval(fetchNewLeads, 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const tabs = [
|
||||
{ href: "/suche", label: "Suche" },
|
||||
{ href: "/leadspeicher", label: "Leadspeicher" },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">OnyvaLeads</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white font-medium">{label}</span>
|
||||
<header
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
height: 52,
|
||||
background: "#111118",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link href="/suche" style={{ textDecoration: "none" }}>
|
||||
<OnyvaLogo />
|
||||
</Link>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div
|
||||
style={{
|
||||
background: "#0a0a0f",
|
||||
borderRadius: 8,
|
||||
padding: 3,
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname === tab.href || pathname.startsWith(tab.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
color: isActive ? "#ffffff" : "#9ca3af",
|
||||
background: isActive ? "#111118" : "transparent",
|
||||
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.2)" : "none",
|
||||
textDecoration: "none",
|
||||
transition: "all 0.15s",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.href === "/leadspeicher" && newLeadsCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
background: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{newLeadsCount > 99 ? "99+" : newLeadsCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{runningJobs > 0 && (
|
||||
<div className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-3 py-1">
|
||||
<Activity className="w-3.5 h-3.5 text-blue-400 animate-pulse" />
|
||||
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
125
components/leadspeicher/BulkActionBar.tsx
Normal file
125
components/leadspeicher/BulkActionBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { STATUS_OPTIONS } from "./StatusBadge";
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number;
|
||||
onSetStatus: (status: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BulkActionBar({ selectedCount, onSetStatus, onDelete }: BulkActionBarProps) {
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
zIndex: 200,
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
|
||||
animation: "slideUp 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#9ca3af", whiteSpace: "nowrap" }}>
|
||||
{selectedCount} ausgewählt
|
||||
</span>
|
||||
<span style={{ color: "#1e1e2e" }}>·</span>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setShowStatusMenu((v) => !v)}
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 6,
|
||||
padding: "5px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
Status setzen ▾
|
||||
</button>
|
||||
{showStatusMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
minWidth: 160,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
zIndex: 300,
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSetStatus(opt.value); setShowStatusMenu(false); }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: "#9ca3af",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#1e1e2e"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "5px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ef4444",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
domain: string | null;
|
||||
companyName: string | null;
|
||||
contactName: string | null;
|
||||
contactTitle: string | null;
|
||||
email: string | null;
|
||||
linkedinUrl: string | null;
|
||||
phone: string | null;
|
||||
industry: string | null;
|
||||
sourceTerm: string | null;
|
||||
address: string | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
interface LeadSidePanelProps {
|
||||
lead: Lead;
|
||||
onClose: () => void;
|
||||
onUpdate: (id: string, updates: Partial<Lead>) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function LeadSidePanel({ lead, onClose, onUpdate }: LeadSidePanelProps) {
|
||||
const [notes, setNotes] = useState(lead.notes || "");
|
||||
const [status, setStatus] = useState(lead.status);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNotes(lead.notes || "");
|
||||
setStatus(lead.status);
|
||||
}, [lead.id, lead.notes, lead.status]);
|
||||
|
||||
const saveNotes = useCallback(
|
||||
async (value: string) => {
|
||||
try {
|
||||
await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notes: value }),
|
||||
});
|
||||
onUpdate(lead.id, { notes: value });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
[lead.id, onUpdate]
|
||||
);
|
||||
|
||||
function handleNotesChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const val = e.target.value;
|
||||
setNotes(val);
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
saveTimeout.current = setTimeout(() => saveNotes(val), 500);
|
||||
}
|
||||
|
||||
async function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newStatus = e.target.value;
|
||||
setStatus(newStatus);
|
||||
try {
|
||||
await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
onUpdate(lead.id, { status: newStatus });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEmail() {
|
||||
if (lead.email) {
|
||||
await navigator.clipboard.writeText(lead.email);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 380,
|
||||
background: "#111118",
|
||||
borderLeft: "1px solid #1e1e2e",
|
||||
zIndex: 400,
|
||||
overflowY: "auto",
|
||||
padding: 24,
|
||||
animation: "slideInRight 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 2 }}>
|
||||
{lead.companyName || lead.domain || "Unbekannt"}
|
||||
</div>
|
||||
{lead.domain && (
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6b7280",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={handleStatusChange}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontSize: 13,
|
||||
color: STATUS_MAP[status]?.color ?? "#ffffff",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
|
||||
|
||||
{/* Email */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
E-Mail
|
||||
</label>
|
||||
{lead.email ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
color: "#3b82f6",
|
||||
flex: 1,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{lead.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyEmail}
|
||||
title="Kopieren"
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 6,
|
||||
padding: "4px 8px",
|
||||
fontSize: 11,
|
||||
color: "#9ca3af",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "#6b7280" }}>— nicht gefunden</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LinkedIn */}
|
||||
{lead.linkedinUrl && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
LinkedIn
|
||||
</label>
|
||||
<a
|
||||
href={lead.linkedinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 13, color: "#3b82f6", wordBreak: "break-all" }}
|
||||
>
|
||||
{lead.linkedinUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone */}
|
||||
{lead.phone && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Telefon
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: "#ffffff" }}>{lead.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industry */}
|
||||
{lead.industry && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Branche
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: "#9ca3af" }}>{lead.industry}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Region */}
|
||||
{(lead.sourceTerm || lead.address) && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Region
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||
{lead.address ?? lead.sourceTerm}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Notiz
|
||||
</label>
|
||||
{saved && (
|
||||
<span style={{ fontSize: 11, color: "#22c55e" }}>Gespeichert ✓</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={handleNotesChange}
|
||||
placeholder="Notizen zu diesem Lead…"
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "#3b82f6";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "#1e1e2e";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #1e1e2e",
|
||||
paddingTop: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: 11, color: "#6b7280" }}>Gefunden am</span>
|
||||
<span style={{ fontSize: 11, color: "#9ca3af" }}>{formatDate(lead.capturedAt)}</span>
|
||||
</div>
|
||||
{lead.sourceTerm && (
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: 11, color: "#6b7280" }}>Suchbegriff</span>
|
||||
<span style={{ fontSize: 11, color: "#9ca3af" }}>{lead.sourceTerm}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
335
components/leadspeicher/LeadsTable.tsx
Normal file
335
components/leadspeicher/LeadsTable.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { StatusPopover } from "./StatusPopover";
|
||||
import { LeadSidePanel } from "./LeadSidePanel";
|
||||
|
||||
export interface Lead {
|
||||
id: string;
|
||||
domain: string | null;
|
||||
companyName: string | null;
|
||||
contactName: string | null;
|
||||
contactTitle: string | null;
|
||||
email: string | null;
|
||||
linkedinUrl: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
sourceTab: string;
|
||||
sourceTerm: string | null;
|
||||
sourceJobId: string | null;
|
||||
serpTitle: string | null;
|
||||
serpSnippet: string | null;
|
||||
serpRank: number | null;
|
||||
serpUrl: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
country: string | null;
|
||||
headcount: string | null;
|
||||
industry: string | null;
|
||||
description: string | null;
|
||||
capturedAt: string;
|
||||
contactedAt: string | null;
|
||||
}
|
||||
|
||||
interface LeadsTableProps {
|
||||
leads: Lead[];
|
||||
selected: Set<string>;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleAll: () => void;
|
||||
allSelected: boolean;
|
||||
onLeadUpdate: (id: string, updates: Partial<Lead>) => void;
|
||||
}
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
const now = new Date();
|
||||
const d = new Date(iso);
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return "heute";
|
||||
if (diffDays === 1) return "gestern";
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 5) return `vor ${diffWeeks} Woche${diffWeeks > 1 ? "n" : ""}`;
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `vor ${diffMonths} Monat${diffMonths > 1 ? "en" : ""}`;
|
||||
}
|
||||
|
||||
function fullDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface StatusCellProps {
|
||||
lead: Lead;
|
||||
onUpdate: (id: string, updates: Partial<Lead>) => void;
|
||||
}
|
||||
|
||||
function StatusCell({ lead, onUpdate }: StatusCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleSelect(newStatus: string) {
|
||||
// Optimistic update
|
||||
onUpdate(lead.id, { status: newStatus });
|
||||
try {
|
||||
await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
} catch {
|
||||
// revert on error
|
||||
onUpdate(lead.id, { status: lead.status });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
<StatusBadge status={lead.status} onClick={() => setOpen((v) => !v)} />
|
||||
{open && (
|
||||
<StatusPopover
|
||||
currentStatus={lead.status}
|
||||
onSelect={handleSelect}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeadsTable({
|
||||
leads,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onToggleAll,
|
||||
allSelected,
|
||||
onLeadUpdate,
|
||||
}: LeadsTableProps) {
|
||||
const [sidePanelLead, setSidePanelLead] = useState<Lead | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const handleCopyEmail = useCallback(async (lead: Lead, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!lead.email) return;
|
||||
await navigator.clipboard.writeText(lead.email);
|
||||
setCopiedId(lead.id);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
}, []);
|
||||
|
||||
async function handleMarkContacted(lead: Lead) {
|
||||
onLeadUpdate(lead.id, { status: "contacted" });
|
||||
try {
|
||||
await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "contacted" }),
|
||||
});
|
||||
} catch {
|
||||
onLeadUpdate(lead.id, { status: lead.status });
|
||||
}
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "10px 16px",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
textAlign: "left",
|
||||
whiteSpace: "nowrap",
|
||||
background: "#0d0d18",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, width: 40 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected && leads.length > 0}
|
||||
onChange={onToggleAll}
|
||||
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
|
||||
/>
|
||||
</th>
|
||||
<th style={thStyle}>Unternehmen</th>
|
||||
<th style={thStyle}>E-Mail</th>
|
||||
<th style={thStyle}>Branche</th>
|
||||
<th style={thStyle}>Region</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Gefunden am</th>
|
||||
<th style={{ ...thStyle, textAlign: "right" }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead) => {
|
||||
const isSelected = selected.has(lead.id);
|
||||
const isNew = lead.status === "new";
|
||||
return (
|
||||
<tr
|
||||
key={lead.id}
|
||||
style={{
|
||||
background: isSelected ? "rgba(59,130,246,0.04)" : "transparent",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "#0d0d18";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<td style={{ padding: "11px 16px", width: 40 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(lead.id)}
|
||||
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Unternehmen */}
|
||||
<td style={{ padding: "11px 16px", maxWidth: 200 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "#ffffff", marginBottom: 1 }}>
|
||||
{lead.companyName || lead.domain || "—"}
|
||||
</div>
|
||||
{lead.domain && lead.companyName && (
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td style={{ padding: "11px 16px", maxWidth: 220 }}>
|
||||
{lead.email ? (
|
||||
<div
|
||||
className="email-cell"
|
||||
style={{ display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
color: "#3b82f6",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{lead.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => handleCopyEmail(lead, e)}
|
||||
title="Kopieren"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
color: copiedId === lead.id ? "#22c55e" : "#6b7280",
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{copiedId === lead.id ? "✓" : "⎘"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: "#6b7280" }}>— nicht gefunden</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Branche */}
|
||||
<td style={{ padding: "11px 16px" }}>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{lead.industry || "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Region */}
|
||||
<td style={{ padding: "11px 16px" }}>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{lead.address ?? lead.sourceTerm ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td style={{ padding: "11px 16px" }}>
|
||||
<StatusCell lead={lead} onUpdate={onLeadUpdate} />
|
||||
</td>
|
||||
|
||||
{/* Gefunden am */}
|
||||
<td style={{ padding: "11px 16px" }}>
|
||||
<span
|
||||
style={{ fontSize: 12, color: "#9ca3af" }}
|
||||
title={fullDate(lead.capturedAt)}
|
||||
>
|
||||
{relativeDate(lead.capturedAt)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Aktionen */}
|
||||
<td style={{ padding: "11px 16px", textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
|
||||
{/* Primary action */}
|
||||
<button
|
||||
onClick={() => (isNew ? handleMarkContacted(lead) : undefined)}
|
||||
style={{
|
||||
background: isNew ? "rgba(59,130,246,0.12)" : "#0d0d18",
|
||||
border: `1px solid ${isNew ? "rgba(59,130,246,0.3)" : "#2e2e3e"}`,
|
||||
borderRadius: 6,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
color: isNew ? "#93c5fd" : "#9ca3af",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{isNew ? "Kontaktiert ✓" : "Status ändern"}
|
||||
</button>
|
||||
{/* Notiz */}
|
||||
<button
|
||||
onClick={() => setSidePanelLead(lead)}
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 6,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
color: "#9ca3af",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Notiz
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
{sidePanelLead && (
|
||||
<LeadSidePanel
|
||||
lead={sidePanelLead}
|
||||
onClose={() => setSidePanelLead(null)}
|
||||
onUpdate={(id, updates) => {
|
||||
onLeadUpdate(id, updates);
|
||||
setSidePanelLead((prev) => (prev && prev.id === id ? { ...prev, ...updates } : prev));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
interface LeadsToolbarProps {
|
||||
search: string;
|
||||
status: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onStatusChange: (v: string) => void;
|
||||
exportParams: Record<string, string>;
|
||||
}
|
||||
|
||||
export function LeadsToolbar({
|
||||
search,
|
||||
status,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
exportParams,
|
||||
}: LeadsToolbarProps) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => onSearchChange(val), 300);
|
||||
// immediate update of input
|
||||
e.currentTarget.value = val;
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const params = new URLSearchParams({ format: "xlsx", ...exportParams });
|
||||
window.location.href = `/api/leads/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 52,
|
||||
zIndex: 40,
|
||||
background: "#111118",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
padding: "14px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "none",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<line x1="9.5" y1="9.5" x2="12.5" y2="12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={search}
|
||||
onChange={handleSearchInput}
|
||||
placeholder="Unternehmen, E-Mail, Branche…"
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px 8px 32px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
style={{
|
||||
width: 150,
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="new">Neu</option>
|
||||
<option value="contacted">Kontaktiert</option>
|
||||
<option value="in_progress">In Bearbeitung</option>
|
||||
<option value="not_relevant">Nicht relevant</option>
|
||||
<option value="converted">Konvertiert</option>
|
||||
</select>
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 14px",
|
||||
fontSize: 13,
|
||||
color: "#9ca3af",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#ffffff"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
|
||||
>
|
||||
↓ Excel Export
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
components/leadspeicher/StatusBadge.tsx
Normal file
54
components/leadspeicher/StatusBadge.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
export type LeadStatus = "new" | "contacted" | "in_progress" | "not_relevant" | "converted";
|
||||
|
||||
interface StatusConfig {
|
||||
label: string;
|
||||
background: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const STATUS_MAP: Record<string, StatusConfig> = {
|
||||
new: { label: "Neu", background: "#1e3a5f", color: "#93c5fd" },
|
||||
contacted: { label: "Kontaktiert", background: "#064e3b", color: "#6ee7b7" },
|
||||
in_progress: { label: "In Bearbeitung", background: "#451a03", color: "#fcd34d" },
|
||||
not_relevant: { label: "Nicht relevant", background: "#1e1e2e", color: "#6b7280" },
|
||||
converted: { label: "Konvertiert", background: "#2e1065", color: "#d8b4fe" },
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: "new", label: "Neu" },
|
||||
{ value: "contacted", label: "Kontaktiert" },
|
||||
{ value: "in_progress", label: "In Bearbeitung" },
|
||||
{ value: "not_relevant", label: "Nicht relevant" },
|
||||
{ value: "converted", label: "Konvertiert" },
|
||||
];
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, onClick }: StatusBadgeProps) {
|
||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.new;
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "3px 10px",
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: cfg.background,
|
||||
color: cfg.color,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
82
components/leadspeicher/StatusPopover.tsx
Normal file
82
components/leadspeicher/StatusPopover.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
|
||||
|
||||
interface StatusPopoverProps {
|
||||
currentStatus: string;
|
||||
onSelect: (status: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function StatusPopover({ currentStatus, onSelect, onClose }: StatusPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
minWidth: 160,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => {
|
||||
const cfg = STATUS_MAP[opt.value];
|
||||
const isActive = opt.value === currentStatus;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSelect(opt.value); onClose(); }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: isActive ? "#ffffff" : "#9ca3af",
|
||||
background: isActive ? "#1e1e2e" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "#0d0d18"; }}
|
||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: cfg.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
components/search/AiSearchModal.tsx
Normal file
222
components/search/AiSearchModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface Query {
|
||||
query: string;
|
||||
region: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface AiSearchModalProps {
|
||||
onStart: (queries: Query[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queries, setQueries] = useState<Query[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function generate() {
|
||||
if (!description.trim() || loading) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setQueries([]);
|
||||
try {
|
||||
const res = await fetch("/api/ai-search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
const data = await res.json() as { queries?: Query[]; error?: string };
|
||||
if (!res.ok || !data.queries) throw new Error(data.error || "Fehler");
|
||||
setQueries(data.queries);
|
||||
setSelected(new Set(data.queries.map((_, i) => i)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unbekannter Fehler");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(i: number) {
|
||||
setSelected(prev => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(i)) n.delete(i); else n.add(i);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
const chosen = queries.filter((_, i) => selected.has(i));
|
||||
if (!chosen.length) return;
|
||||
onStart(chosen);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 50,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#111118", border: "1px solid #1e1e2e", borderRadius: 14,
|
||||
padding: 28, width: "100%", maxWidth: 520,
|
||||
display: "flex", flexDirection: "column", gap: 16,
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 18 }}>✨</span>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 500, color: "#fff" }}>
|
||||
KI-gestützte Suche
|
||||
</h2>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "#6b7280" }}>
|
||||
Beschreibe deine Zielgruppe — die KI generiert passende Suchanfragen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 4 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter" && e.ctrlKey) generate(); }}
|
||||
placeholder="z.B. Ich suche kleine Dachdecker und Solarinstallateure in Bayern, am liebsten mit Inhaber direkt erreichbar."
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%", background: "#0d0d18", border: "1px solid #1e1e2e",
|
||||
borderRadius: 8, padding: "10px 12px", fontSize: 13, color: "#fff",
|
||||
outline: "none", resize: "vertical", fontFamily: "inherit",
|
||||
lineHeight: 1.6, boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = "#3b82f6"; }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
|
||||
/>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 11, color: "#4b5563" }}>
|
||||
Tipp: Branche, Region, Firmengröße und gewünschten Entscheidungsträger erwähnen · Strg+Enter zum Generieren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
{queries.length === 0 && (
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={!description.trim() || loading}
|
||||
style={{
|
||||
width: "100%", padding: "11px 16px", borderRadius: 8, border: "none",
|
||||
background: !description.trim() || loading ? "#1e1e2e" : "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
color: !description.trim() || loading ? "#4b5563" : "#fff",
|
||||
fontSize: 14, fontWeight: 500, cursor: !description.trim() || loading ? "not-allowed" : "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: "spin 1s linear infinite" }}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
Suchanfragen werden generiert…
|
||||
</>
|
||||
) : (
|
||||
<>✨ Suchanfragen generieren</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "#ef4444", background: "rgba(239,68,68,0.08)", padding: "8px 12px", borderRadius: 6, borderLeft: "3px solid #ef4444" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Generated queries */}
|
||||
{queries.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Generierte Suchanfragen — wähle aus was starten soll
|
||||
</p>
|
||||
{queries.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => toggle(i)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "10px 14px", borderRadius: 8, cursor: "pointer", textAlign: "left",
|
||||
border: selected.has(i) ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
|
||||
background: selected.has(i) ? "rgba(59,130,246,0.08)" : "#0d0d18",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontSize: 13, color: "#fff", fontWeight: 500 }}>{q.query}</span>
|
||||
{q.region && (
|
||||
<span style={{ fontSize: 12, color: "#6b7280", marginLeft: 8 }}>📍 {q.region}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4,
|
||||
border: selected.has(i) ? "none" : "1px solid #2e2e3e",
|
||||
background: selected.has(i) ? "#3b82f6" : "transparent",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||
}}>
|
||||
{selected.has(i) && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 5l2.5 2.5L8 3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => { setQueries([]); setSelected(new Set()); }}
|
||||
style={{
|
||||
flex: 1, padding: "10px", borderRadius: 8, border: "1px solid #1e1e2e",
|
||||
background: "#0d0d18", color: "#6b7280", fontSize: 13, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Neu generieren
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={selected.size === 0}
|
||||
style={{
|
||||
flex: 2, padding: "10px", borderRadius: 8, border: "none",
|
||||
background: selected.size > 0 ? "linear-gradient(135deg, #3b82f6, #8b5cf6)" : "#1e1e2e",
|
||||
color: selected.size > 0 ? "#fff" : "#4b5563",
|
||||
fontSize: 13, fontWeight: 500, cursor: selected.size > 0 ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{selected.size} {selected.size === 1 ? "Suche" : "Suchen"} starten →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
components/search/ExamplePills.tsx
Normal file
53
components/search/ExamplePills.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
interface ExamplePill {
|
||||
label: string;
|
||||
query: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const EXAMPLES: ExamplePill[] = [
|
||||
{ label: "☀️ Solaranlagen Bayern", query: "Solaranlagen", region: "Bayern" },
|
||||
{ label: "🔧 Dachdecker NRW", query: "Dachdecker", region: "NRW" },
|
||||
{ label: "📊 Steuerberater Berlin", query: "Steuerberater", region: "Berlin" },
|
||||
{ label: "🏗️ Bauunternehmen Süddeutschland", query: "Bauunternehmen", region: "Süddeutschland" },
|
||||
{ label: "🌿 Landschaftsgärtner Hamburg", query: "Landschaftsgärtner", region: "Hamburg" },
|
||||
{ label: "🔌 Elektroinstallateur München", query: "Elektroinstallateur", region: "München" },
|
||||
];
|
||||
|
||||
interface ExamplePillsProps {
|
||||
onSelect: (query: string, region: string) => void;
|
||||
}
|
||||
|
||||
export function ExamplePills({ onSelect }: ExamplePillsProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex.label}
|
||||
onClick={() => onSelect(ex.query, ex.region)}
|
||||
style={{
|
||||
padding: "5px 12px",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "#3b82f6";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "#3b82f6";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "#2e2e3e";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
|
||||
}}
|
||||
>
|
||||
{ex.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
components/search/LoadingCard.tsx
Normal file
351
components/search/LoadingCard.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Phase = "scraping" | "enriching" | "emails" | "topping" | "done";
|
||||
|
||||
interface JobStatus {
|
||||
status: "running" | "complete" | "failed";
|
||||
totalLeads: number;
|
||||
emailsFound: number;
|
||||
error?: string | null;
|
||||
results?: LeadResult[];
|
||||
}
|
||||
|
||||
export interface LeadResult {
|
||||
id: string;
|
||||
companyName: string | null;
|
||||
domain: string | null;
|
||||
contactName: string | null;
|
||||
contactTitle: string | null;
|
||||
email: string | null;
|
||||
linkedinUrl: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
createdAt: string;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface LoadingCardProps {
|
||||
jobId: string;
|
||||
targetCount: number;
|
||||
query: string;
|
||||
region: string;
|
||||
onDone: (leads: LeadResult[], warning?: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: "scraping" as Phase, label: "Unternehmen gefunden" },
|
||||
{ id: "enriching" as Phase, label: "Kontakte ermitteln" },
|
||||
{ id: "emails" as Phase, label: "E-Mails suchen" },
|
||||
{ id: "done" as Phase, label: "Fertig" },
|
||||
];
|
||||
|
||||
function getPhase(s: JobStatus): Phase {
|
||||
if (s.status === "complete") return "done";
|
||||
if (s.emailsFound > 0) return "emails";
|
||||
if (s.totalLeads > 0) return "enriching";
|
||||
return "scraping";
|
||||
}
|
||||
|
||||
function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pending" {
|
||||
const order: Phase[] = ["scraping", "enriching", "emails", "done"];
|
||||
const stepIdx = order.indexOf(step);
|
||||
const currentIdx = order.indexOf(currentPhase);
|
||||
if (stepIdx < currentIdx) return "done";
|
||||
if (stepIdx === currentIdx) return "active";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
const PHASE_MIN: Record<Phase, number> = {
|
||||
scraping: 3,
|
||||
enriching: 35,
|
||||
emails: 60,
|
||||
topping: 88,
|
||||
done: 100,
|
||||
};
|
||||
|
||||
export function LoadingCard({ jobId, targetCount, query, region, onDone, onError }: LoadingCardProps) {
|
||||
const [phase, setPhase] = useState<Phase>("scraping");
|
||||
const [totalLeads, setTotalLeads] = useState(0);
|
||||
const [emailsFound, setEmailsFound] = useState(0);
|
||||
const [progressWidth, setProgressWidth] = useState(3);
|
||||
const [isTopping, setIsTopping] = useState(false);
|
||||
const [optimizedQuery, setOptimizedQuery] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let crawlInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let mapsPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let serpPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Parallel tracking
|
||||
let supplementStarted = false;
|
||||
let mapsDone = false;
|
||||
let serpDone = false;
|
||||
let serpNeeded = false;
|
||||
let mapsLeads: LeadResult[] = [];
|
||||
let serpLeads: LeadResult[] = [];
|
||||
let serpJobId: string | null = null;
|
||||
let lastTotalLeads = 0; // detect when Maps scraping has stabilized
|
||||
|
||||
function advanceBar(to: number) {
|
||||
setProgressWidth(prev => Math.max(prev, to));
|
||||
}
|
||||
|
||||
crawlInterval = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
setProgressWidth(prev => prev >= 88 ? prev : prev + 0.4);
|
||||
}, 200);
|
||||
|
||||
// Called when both Maps and SERP (if needed) are done
|
||||
function tryFinalize() {
|
||||
if (!mapsDone || (serpNeeded && !serpDone)) return;
|
||||
if (crawlInterval) clearInterval(crawlInterval);
|
||||
|
||||
let finalLeads: LeadResult[];
|
||||
if (serpNeeded && serpLeads.length > 0) {
|
||||
const seenDomains = new Set(mapsLeads.map(l => l.domain).filter(Boolean));
|
||||
const newSerpLeads = serpLeads.filter(l => !l.domain || !seenDomains.has(l.domain));
|
||||
finalLeads = [...mapsLeads, ...newSerpLeads];
|
||||
} else {
|
||||
finalLeads = mapsLeads;
|
||||
}
|
||||
|
||||
setTotalLeads(finalLeads.length);
|
||||
setProgressWidth(100);
|
||||
setPhase("done");
|
||||
setTimeout(() => { if (!cancelled) onDone(finalLeads); }, 800);
|
||||
}
|
||||
|
||||
// Start SERP supplement in parallel — doesn't block Maps polling
|
||||
async function startSerpSupplement(foundCount: number) {
|
||||
supplementStarted = true;
|
||||
serpNeeded = true;
|
||||
setIsTopping(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/search/supplement", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, region, targetCount, foundCount }),
|
||||
});
|
||||
if (!res.ok) throw new Error("supplement start failed");
|
||||
const data = await res.json() as { jobId: string; optimizedQuery: string; usedAI: boolean };
|
||||
serpJobId = data.jobId;
|
||||
if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery);
|
||||
serpPollTimeout = setTimeout(pollSerp, 2500);
|
||||
} catch {
|
||||
// Supplement failed — mark done with no results so tryFinalize can proceed
|
||||
serpDone = true;
|
||||
tryFinalize();
|
||||
}
|
||||
}
|
||||
|
||||
// Independent SERP poll loop
|
||||
async function pollSerp() {
|
||||
if (cancelled || !serpJobId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${serpJobId}/status`);
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
const data = await res.json() as JobStatus;
|
||||
if (!cancelled) {
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
serpLeads = (data.results ?? []) as LeadResult[];
|
||||
serpDone = true;
|
||||
tryFinalize();
|
||||
} else {
|
||||
serpPollTimeout = setTimeout(pollSerp, 2500);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) serpPollTimeout = setTimeout(pollSerp, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Maps poll loop
|
||||
async function pollMaps() {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobId}/status`);
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
const data = await res.json() as JobStatus;
|
||||
|
||||
if (!cancelled) {
|
||||
setTotalLeads(data.totalLeads);
|
||||
setEmailsFound(data.emailsFound);
|
||||
const p = getPhase(data);
|
||||
setPhase(p);
|
||||
advanceBar(PHASE_MIN[p]);
|
||||
|
||||
// Fire supplement as soon as Maps scraping stabilizes with fewer results than needed.
|
||||
// "Stabilized" = totalLeads unchanged since last poll (scraping done, enrichment started).
|
||||
if (
|
||||
!supplementStarted &&
|
||||
data.status === "running" &&
|
||||
data.totalLeads > 0 &&
|
||||
data.totalLeads < targetCount &&
|
||||
data.totalLeads === lastTotalLeads
|
||||
) {
|
||||
startSerpSupplement(data.totalLeads).catch(console.error);
|
||||
}
|
||||
lastTotalLeads = data.totalLeads;
|
||||
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
const leads = (data.results ?? []) as LeadResult[];
|
||||
|
||||
if (data.status === "failed" && leads.length === 0) {
|
||||
if (crawlInterval) clearInterval(crawlInterval);
|
||||
onError(data.error ?? "Unbekannter Fehler");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flash "E-Mails suchen" step before completing (poll often misses it)
|
||||
setPhase("emails");
|
||||
advanceBar(PHASE_MIN["emails"]);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
if (cancelled) return;
|
||||
|
||||
mapsLeads = leads;
|
||||
mapsDone = true;
|
||||
|
||||
// If supplement wasn't triggered, no SERP needed
|
||||
if (!supplementStarted) serpDone = true;
|
||||
|
||||
// Trigger supplement now if Maps finished with fewer results and we haven't yet
|
||||
if (!supplementStarted && data.totalLeads < targetCount) {
|
||||
startSerpSupplement(data.totalLeads).catch(console.error);
|
||||
} else {
|
||||
tryFinalize();
|
||||
}
|
||||
} else {
|
||||
mapsPollTimeout = setTimeout(pollMaps, 2500);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) mapsPollTimeout = setTimeout(pollMaps, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
pollMaps();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (crawlInterval) clearInterval(crawlInterval);
|
||||
if (mapsPollTimeout) clearTimeout(mapsPollTimeout);
|
||||
if (serpPollTimeout) clearTimeout(serpPollTimeout);
|
||||
};
|
||||
}, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
|
||||
{isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{totalLeads > 0 || emailsFound > 0
|
||||
? `${emailsFound} E-Mails · ${totalLeads} Unternehmen`
|
||||
: "Wird gestartet…"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar — only ever moves forward */}
|
||||
<div
|
||||
style={{
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: "#1e1e2e",
|
||||
overflow: "hidden",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressWidth}%`,
|
||||
background: "linear-gradient(90deg, #3b82f6, #8b5cf6)",
|
||||
borderRadius: 3,
|
||||
transition: phase === "done" ? "width 0.5s ease" : "width 0.12s linear",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div style={{ display: "flex", gap: 20, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
{STEPS.map((step) => {
|
||||
const state = isTopping ? "done" : stepState(step.id, phase);
|
||||
return (
|
||||
<div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: state === "done" ? "#22c55e" : state === "active" ? "#3b82f6" : "#2e2e3e",
|
||||
animation: state === "active" ? "pulse 1.5s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: state === "done" ? "#22c55e" : state === "active" ? "#ffffff" : "#6b7280",
|
||||
}}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isTopping && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: 3, background: "#3b82f6", animation: "pulse 1.5s infinite" }} />
|
||||
<span style={{ fontSize: 12, color: "#ffffff" }}>Ergebnisse auffüllen</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optimized query hint */}
|
||||
{isTopping && optimizedQuery && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 14, fontSize: 11, color: "#6b7280" }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2.5">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636-.707.707M21 12h-1M4 12H3m3.343-5.657-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||
</svg>
|
||||
KI-optimierte Suche:
|
||||
<span style={{ color: "#9ca3af", fontStyle: "italic" }}>„{optimizedQuery}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning banner */}
|
||||
<div
|
||||
style={{
|
||||
borderLeft: "3px solid #f59e0b",
|
||||
background: "rgba(245,158,11,0.08)",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
⚠️ Bitte diesen Tab nicht schließen, während die Suche läuft.
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
components/search/SearchCard.tsx
Normal file
162
components/search/SearchCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { ExamplePills } from "./ExamplePills";
|
||||
|
||||
interface SearchCardProps {
|
||||
query: string;
|
||||
region: string;
|
||||
count: number;
|
||||
loading: boolean;
|
||||
onChange: (field: "query" | "region" | "count", value: string | number) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
fontSize: 14,
|
||||
color: "#ffffff",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
transition: "border-color 0.15s",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
export function SearchCard({ query, region, count, loading, onChange, onSubmit }: SearchCardProps) {
|
||||
const canSubmit = query.trim().length > 0 && !loading;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
opacity: loading ? 0.55 : 1,
|
||||
pointerEvents: loading ? "none" : "auto",
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.search-input:hover { border-color: #2e2e3e !important; }
|
||||
.search-input:focus { border-color: #3b82f6 !important; }
|
||||
.search-btn:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); }
|
||||
.search-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.search-btn { transition: filter 0.15s, transform 0.15s, opacity 0.15s; }
|
||||
@media (max-width: 600px) {
|
||||
.search-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 3-column grid */}
|
||||
<div
|
||||
className="search-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 120px",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{/* Suchbegriff */}
|
||||
<div>
|
||||
<label style={labelStyle}>Suchbegriff</label>
|
||||
<input
|
||||
className="search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => onChange("query", e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
|
||||
placeholder="z.B. Dachdecker, Solaranlage…"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Region */}
|
||||
<div>
|
||||
<label style={labelStyle}>Region</label>
|
||||
<input
|
||||
className="search-input"
|
||||
type="text"
|
||||
value={region}
|
||||
onChange={(e) => onChange("region", e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
|
||||
placeholder="z.B. Bayern, München…"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
<div>
|
||||
<label style={labelStyle}>Anzahl</label>
|
||||
<select
|
||||
className="search-input"
|
||||
value={count}
|
||||
onChange={(e) => onChange("count", Number(e.target.value))}
|
||||
style={{ ...inputStyle, cursor: "pointer" }}
|
||||
>
|
||||
<option value={25}>25 Leads</option>
|
||||
<option value={50}>50 Leads</option>
|
||||
<option value={100}>100 Leads</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: canSubmit ? "linear-gradient(135deg, #3b82f6, #8b5cf6)" : "#1e1e2e",
|
||||
color: canSubmit ? "#ffffff" : "#6b7280",
|
||||
border: "none",
|
||||
borderRadius: 10,
|
||||
padding: "12px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: canSubmit ? "pointer" : "not-allowed",
|
||||
opacity: canSubmit ? 1 : 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<line x1="10.5" y1="10.5" x2="13.5" y2="13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Leads suchen
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
|
||||
<span style={{ fontSize: 12, color: "#6b7280", flexShrink: 0 }}>Beispielsuche</span>
|
||||
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
|
||||
</div>
|
||||
|
||||
<ExamplePills
|
||||
onSelect={(q, r) => {
|
||||
onChange("query", q);
|
||||
onChange("region", r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,33 +35,25 @@ export async function sinkLeadToVault(
|
||||
const email = lead.email || null;
|
||||
|
||||
if (domain) {
|
||||
// Check for exact duplicate (same domain + same email)
|
||||
if (email) {
|
||||
const exact = await prisma.lead.findFirst({
|
||||
where: { domain, email },
|
||||
});
|
||||
if (exact) return exact.id; // already exists, skip
|
||||
}
|
||||
// Strict domain dedup: one lead per domain
|
||||
const existing = await prisma.lead.findFirst({ where: { domain } });
|
||||
|
||||
// Check if same domain exists without email and we now have one
|
||||
const existing = await prisma.lead.findFirst({
|
||||
where: { domain, email: null },
|
||||
});
|
||||
|
||||
if (existing && email) {
|
||||
// Upgrade: fill in email + other missing fields
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
if (existing) {
|
||||
if (email && !existing.email) {
|
||||
// Upgrade: fill in email + other missing fields
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return existing.id; // domain already exists → skip or upgraded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,26 +98,30 @@ export async function sinkLeadsToVault(
|
||||
const domain = lead.domain || null;
|
||||
const email = lead.email || null;
|
||||
|
||||
if (domain) {
|
||||
if (email) {
|
||||
const exact = await prisma.lead.findFirst({ where: { domain, email } });
|
||||
if (exact) { skipped++; continue; }
|
||||
}
|
||||
if (!domain) { skipped++; continue; } // no domain → skip entirely
|
||||
|
||||
const existing = await prisma.lead.findFirst({ where: { domain, email: null } });
|
||||
if (existing && email) {
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
if (domain) {
|
||||
// Strict domain dedup: one lead per domain
|
||||
const existing = await prisma.lead.findFirst({ where: { domain } });
|
||||
|
||||
if (existing) {
|
||||
if (email && !existing.email) {
|
||||
// Upgrade: existing has no email, new one does → fill it in
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
skipped++; // domain already exists → skip
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +136,7 @@ export async function sinkLeadsToVault(
|
||||
linkedinUrl: lead.linkedinUrl || null,
|
||||
phone: lead.phone || null,
|
||||
address: lead.address || null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpSnippet: lead.serpSnippet || null,
|
||||
serpRank: lead.serpRank ?? null,
|
||||
serpUrl: lead.serpUrl || null,
|
||||
|
||||
50
lib/services/openai.ts
Normal file
50
lib/services/openai.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
/**
|
||||
* Generates an optimized alternative search query when the primary Maps search
|
||||
* returned fewer results than requested. Uses GPT-4.1 to find synonyms,
|
||||
* related terms, or slight regional variations that surface different businesses.
|
||||
*
|
||||
* Returns null if OpenAI is not configured or the call fails (caller should fall
|
||||
* back to the original query).
|
||||
*/
|
||||
export async function generateSupplementQuery(
|
||||
query: string,
|
||||
region: string,
|
||||
foundCount: number,
|
||||
targetCount: number,
|
||||
apiKey: string,
|
||||
): Promise<string | null> {
|
||||
const client = new OpenAI({ apiKey });
|
||||
const searchQuery = region ? `${query} ${region}` : query;
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model: "gpt-4.1",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"Du bist ein Experte für B2B-Lead-Generierung in Deutschland. " +
|
||||
"Antworte immer nur mit der Suchanfrage selbst — keine Anführungszeichen, keine Erklärungen.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
`Eine Google-Suche nach "${searchQuery}" hat nur ${foundCount} von ${targetCount} Unternehmen gefunden. ` +
|
||||
`Erstelle eine alternative Suchanfrage (max. 6 Wörter), die weitere passende Unternehmen findet, ` +
|
||||
`die die erste Suche nicht erfasst hat. Nutze Synonyme, verwandte Branchen-Begriffe oder ` +
|
||||
`leichte Variationen der Region — aber halte den Fokus auf dieselbe Branche und Region.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 30,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const text = response.choices[0]?.message?.content?.trim();
|
||||
return text || null;
|
||||
} catch (err) {
|
||||
console.error("OpenAI query generation failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const ENV_VARS: Record<string, string> = {
|
||||
apify: "APIFY_API_KEY",
|
||||
vayne: "VAYNE_API_KEY",
|
||||
googlemaps: "GOOGLE_MAPS_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
};
|
||||
|
||||
export async function getApiKey(service: string): Promise<string | null> {
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "leadflow",
|
||||
"name": "onyva-leads",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "leadflow",
|
||||
"name": "onyva-leads",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
@@ -26,6 +26,7 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.33.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "19.2.3",
|
||||
@@ -9015,6 +9016,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.33.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz",
|
||||
"integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.33.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "19.2.3",
|
||||
|
||||
Reference in New Issue
Block a user