24 Commits

Author SHA1 Message Date
Timo Uttenweiler
e5172cbdc5 Remove count from AI search prompt, add query prioritization
- count no longer part of prompt or JSON output — fixed at 50 in backend
- Added prioritization rules: most common term wins (Dachdecker > Spengler)
- Cleaner examples without count field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:07:11 +02:00
Timo Uttenweiler
edbf8cb1e2 Improve AI search system prompt
- Explains Google Maps pipeline context so model understands query constraints
- Adds splitting strategy for large regions and multi-industry searches
- 4 concrete JSON examples covering common use cases
- count derived from user context, no hardcoded default in prompt
- Strict output format instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:03:19 +02:00
Timo Uttenweiler
0f5d18dac7 Add KI-Suche via OpenRouter GPT-4o-mini
- /api/ai-search: sends user description to GPT-4o-mini, returns 2-4
  structured query/region pairs as JSON
- AiSearchModal: textarea, generates previews, user selects queries to run
- KI-Suche button in hero section of /suche page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:00:54 +02:00
TimoUttenweiler
a39a98b6dc feat: start SERP supplement in parallel as soon as Maps scraping stabilizes
When Maps scraping finishes (totalLeads stable for one poll, < targetCount),
fire the SERP supplement job immediately — don't wait for Anymailfinder
enrichment to complete. Both jobs now poll independently; results are merged
and deduplicated by domain once both are done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:28:41 +02:00
TimoUttenweiler
1719062b47 feat: Hover-Effekt für Nur-neue-speichern Button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:01:33 +02:00
TimoUttenweiler
f94f7c6314 feat: Nur neue speichern als Button in den Suchergebnissen
Button erscheint in der Ergebnis-Headerzeile sobald vorhandene Leads
dabei sind. Löscht diese aus dem Leadspeicher und zeigt Bilanz-Toast.
Verschwindet nach Ausführung. Checkbox vor der Suche entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:00:10 +02:00
TimoUttenweiler
6264ebf786 feat: Alle/Nur-neue Tabs + Option Nur neue Leads speichern
- Suchergebnis-Filter als Tabs: [Alle (46)] [Nur neue (X)] — beide klickbar
- Checkbox "Nur neue Leads speichern" erscheint vor der Suche
- Bei aktivierter Option: nach Abschluss werden vorhandene Leads
  automatisch aus dem Leadspeicher gelöscht, Toast zeigt Bilanz

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:56:30 +02:00
TimoUttenweiler
25234b70ee fix: Leads ohne Domain werden gefiltert und nicht gespeichert
Maps-Ergebnisse ohne Domain werden vor Speicherung herausgefiltert.
sinkLeadsToVault überspringt Leads ohne Domain komplett.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:54:30 +02:00
TimoUttenweiler
929d5ab3a1 fix: Leads ohne E-Mails wenn Anymailfinder-Guthaben leer
Anymailfinder-Fehler (z.B. 402) markiert Job nicht mehr als failed.
Maps- und SERP-Jobs schließen als complete ab und liefern die gefundenen
Unternehmen ohne Kontaktdaten — SERP-Supplement triggert danach normal.

- maps-enrich: Anymailfinder in eigenem try-catch, Fehler → complete
- serp-enrich: SERP-Rohdaten zuerst speichern, dann Enrichment versuchen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:52:50 +02:00
TimoUttenweiler
89a176700d feat: GPT-4.1 optimierte Ergänzungssuche bei Maps-Lücke
Wenn Google Maps weniger Leads findet als angefragt, wird automatisch
eine optimierte Suchanfrage via GPT-4.1 generiert und als SERP-Job
gestartet, um die Lücke zu füllen. Die KI-Query wird im LoadingCard
angezeigt. Fallback auf Original-Query wenn kein OpenAI-Key konfiguriert.

- lib/services/openai.ts: GPT-4.1 Query-Generator
- app/api/search/supplement: neuer Endpoint (GPT + SERP-Job)
- LoadingCard: ruft /api/search/supplement statt direkt SERP
- apiKey.ts + .env.local.example: openai Key-Support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:43:33 +02:00
TimoUttenweiler
aa6707b8bc feat: Kundensuche – Progressbar, SERP-Supplement, Dedup, Löschen, Neu-Filter
- Progressbar geht nie mehr rückwärts (Math.min-Cap entfernt)
- E-Mails-suchen-Phase wird immer kurz angezeigt bevor Fertig
- SERP-Supplement startet automatisch wenn Maps < Zielanzahl liefert
- Suchergebnisse bleiben nach Abschluss sichtbar (kein Redirect)
- Dedup in leadVault strikt nach Domain (verhindert Duplikate)
- isNew-Flag pro Result (Batch-Query gegen bestehende Vault-Domains)
- Nur-neue-Filter + vorhanden-Badge in Suchergebnissen
- Einzeln und Bulk löschen aus Suchergebnissen + Leadspeicher
- Fehlermeldung zeigt echten API-Fehler (z.B. 402 Anymailfinder)
- SERP-Supplement aus /api/search entfernt (LoadingCard übernimmt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:25:43 +02:00
Timo Uttenweiler
c232f0cb79 Fix stats: count kontaktiert via tag instead of status field 2026-03-27 17:15:09 +01:00
Timo Uttenweiler
11197c9db1 Progress bar: forward-only crawl, never goes backward 2026-03-27 17:13:30 +01:00
Timo Uttenweiler
4c82e96f5a Remove 200 leads option from search 2026-03-27 17:11:03 +01:00
Timo Uttenweiler
a1d2c34f36 Search: Maps primary + SERP supplement for count > 60
- Always use Google Maps (max 60 per call)
- If count > 60: fire SERP job in background for additional results
- Dedup handled automatically by LeadVault domain upsert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:10:09 +01:00
Timo Uttenweiler
bf3fcd4210 Increase suche page padding and max-width 2026-03-27 17:06:04 +01:00
Timo Uttenweiler
8c00d4ea6f Increase leadspeicher padding to 72px 2026-03-27 17:05:34 +01:00
Timo Uttenweiler
b2a963a901 Improve SearchCard: hover effects, responsive grid, smooth transitions 2026-03-27 17:04:23 +01:00
Timo Uttenweiler
6b7ac42d1d Style suche hero like admin maps header 2026-03-27 17:03:12 +01:00
Timo Uttenweiler
9939ba9fdb Update leadspeicher subtitle text 2026-03-27 17:02:17 +01:00
Timo Uttenweiler
ea3138ac64 Remove Quelle column from customer leadspeicher 2026-03-27 17:01:56 +01:00
Timo Uttenweiler
a67dd2cc8c Add padding to leadspeicher page 2026-03-27 17:01:07 +01:00
Timo Uttenweiler
df90477bef Use admin leadspeicher UI in customer branch, fix Topbar import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:59:59 +01:00
Timo Uttenweiler
60073b97c9 feat: OnyvaLeads customer UI — Suche + Leadspeicher
- New Topbar: logo, 2-tab pill switcher, live "Neu" badge
- /suche page: SearchCard, LoadingCard, ExamplePills
- /leadspeicher page: full leads table with filters, pagination
- StatusBadge, StatusPopover, LeadSidePanel, BulkActionBar
- POST /api/search: unified search entry point → serp-enrich
- Remove Sidebar + old TopBar from layout
- Title: OnyvaLeads, redirect / → /suche

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:48:05 +01:00
29 changed files with 4071 additions and 202 deletions

View File

@@ -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
View 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 24 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 });
}
}

View File

@@ -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),
};
}),
});

View File

@@ -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,12 +112,18 @@ 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!);
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;
}
// Map domain → placeId for updating results
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 },
@@ -155,7 +165,7 @@ async function runMapsEnrich(
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: places.length },
data: { status: "complete", emailsFound, totalLeads: placesWithDomain.length },
});
// Update vault entries with enrichment results
@@ -173,6 +183,14 @@ async function runMapsEnrich(
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 },
});
}
} else {
await prisma.job.update({
where: { id: jobId },

View File

@@ -97,7 +97,35 @@ async function runSerpEnrich(
data: { totalLeads: domains.length },
});
// 7. Enrich with Anymailfinder Bulk API
// 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: r.title || null,
domain: r.domain || null,
source: JSON.stringify({ url: r.url, description: r.description, position: r.position }),
},
});
}
// 8. Sink raw results to vault immediately (no contact info yet)
await sinkLeadsToVault(
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,
@@ -107,40 +135,45 @@ async function runSerpEnrich(
}
);
// 8. Store results
// 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 serpData = serpMap.get(result.domain || "");
const hasEmail = !!result.valid_email;
if (hasEmail) emailsFound++;
await prisma.leadResult.create({
const resultId = domainToResultId.get(result.domain || "");
if (resultId) {
await prisma.leadResult.update({
where: { id: resultId },
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,
}),
},
});
}
}
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
data: { status: "complete", emailsFound, totalLeads: filteredResults.length },
});
// Sync to LeadVault
// Update vault entries with contact info
await sinkLeadsToVault(
enrichResults.map(r => {
enrichResults
.filter(r => r.email)
.map(r => {
const serpData = serpMap.get(r.domain || "");
return {
domain: r.domain || null,
@@ -159,6 +192,14 @@ async function runSerpEnrich(
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({

View 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 });
}
}

View File

@@ -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
View 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 });
}
}

View 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 });
}
}

View File

@@ -1,30 +1,23 @@
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">
<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>
</div>
<Toaster position="bottom-right" theme="dark" />
</body>
</html>

916
app/leadspeicher/page.tsx Normal file
View 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>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/airscale");
redirect("/suche");
}

506
app/suche/page.tsx Normal file
View 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>
);
}

View File

@@ -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>
</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
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>
</header>
);
}

View 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>
);
}

View 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,
}}
>
&times;
</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>
</>
);
}

View 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));
}}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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:&nbsp;
<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>
);
}

View 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>
);
}

View File

@@ -35,20 +35,11 @@ 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) {
if (existing) {
if (email && !existing.email) {
// Upgrade: fill in email + other missing fields
await prisma.lead.update({
where: { id: existing.id },
@@ -61,7 +52,8 @@ export async function sinkLeadToVault(
updatedAt: new Date(),
},
});
return existing.id;
}
return existing.id; // domain already exists → skip or upgraded
}
}
@@ -106,14 +98,15 @@ 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) {
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: {
@@ -126,6 +119,9 @@ export async function sinkLeadsToVault(
},
});
updated++;
} else {
skipped++; // domain already exists → skip
}
continue;
}
}

50
lib/services/openai.ts Normal file
View 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;
}
}

View File

@@ -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
View File

@@ -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",

View File

@@ -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",