diff --git a/app/api/jobs/[id]/status/route.ts b/app/api/jobs/[id]/status/route.ts index d75d9e7..96b3031 100644 --- a/app/api/jobs/[id]/status/route.ts +++ b/app/api/jobs/[id]/status/route.ts @@ -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), }; }), }); diff --git a/app/api/leads/delete-from-results/route.ts b/app/api/leads/delete-from-results/route.ts new file mode 100644 index 0000000..89dd0ed --- /dev/null +++ b/app/api/leads/delete-from-results/route.ts @@ -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 }); + } +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 628a181..81da0a4 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -32,24 +32,6 @@ export async function POST(req: NextRequest) { const { jobId } = await mapsRes.json() as { jobId: string }; - // ── 2. SERP supplement (only when count > 60) — fire & forget ──────────── - if (count > 60) { - const extraPages = Math.ceil((count - 60) / 10); - fetch(`${base}/api/jobs/serp-enrich`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: searchQuery, - maxPages: Math.min(extraPages, 10), - countryCode: "de", - languageCode: "de", - filterSocial: true, - categories: ["ceo"], - enrichEmails: true, - }), - }).catch(() => {}); // background — don't block response - } - return NextResponse.json({ jobId }); } catch (err) { console.error("POST /api/search error:", err); diff --git a/app/suche/page.tsx b/app/suche/page.tsx index c562153..8faac7e 100644 --- a/app/suche/page.tsx +++ b/app/suche/page.tsx @@ -1,18 +1,22 @@ "use client"; import { useState, useCallback } from "react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; import { toast } from "sonner"; import { SearchCard } from "@/components/search/SearchCard"; -import { LoadingCard } from "@/components/search/LoadingCard"; +import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard"; export default function SuchePage() { - const router = useRouter(); const [query, setQuery] = useState(""); const [region, setRegion] = useState(""); const [count, setCount] = useState(50); const [loading, setLoading] = useState(false); const [jobId, setJobId] = useState(null); + const [leads, setLeads] = useState([]); + const [searchDone, setSearchDone] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [deleting, setDeleting] = useState(false); + const [onlyNew, setOnlyNew] = useState(false); function handleChange(field: "query" | "region" | "count", value: string | number) { if (field === "query") setQuery(value as string); @@ -24,6 +28,10 @@ export default function SuchePage() { if (!query.trim() || loading) return; setLoading(true); setJobId(null); + setLeads([]); + setSearchDone(false); + setSelected(new Set()); + setOnlyNew(false); try { const res = await fetch("/api/search", { method: "POST", @@ -43,20 +51,44 @@ export default function SuchePage() { } } - const handleDone = useCallback((total: number) => { + const handleDone = useCallback((result: LeadResult[], warning?: string) => { setLoading(false); - toast.success(`✓ ${total} Leads gefunden — Leadspeicher wird geöffnet`, { - duration: 3000, - }); - setTimeout(() => { - router.push("/leadspeicher"); - }, 1500); - }, [router]); + 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 }); + } + }, []); - const handleError = useCallback(() => { + 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); + } + } + + const handleError = useCallback((message: string) => { setLoading(false); setJobId(null); - toast.error("Suche fehlgeschlagen. Bitte prüfe deine API-Einstellungen."); + toast.error(`Suche fehlgeschlagen: ${message}`); }, []); return ( @@ -99,10 +131,243 @@ export default function SuchePage() { {loading && jobId && ( )} + + {/* 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 ( +
+ + + {/* Header */} +
+
+ {/* Select-all checkbox */} + { 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 }} + /> + + {selectedVisible.length > 0 + ? `${selectedVisible.length} ausgewählt` + : `${visibleLeads.length} Leads`} + + + {/* Nur neue Filter */} + +
+ +
+ {selectedVisible.length > 0 && ( + + )} + + Im Leadspeicher → + +
+
+ + {/* Table */} +
+ + + + + ))} + + + + {visibleLeads.map((lead, i) => { + const isSelected = selected.has(lead.id); + return ( + + + + + + + + + ); + })} + +
+ {["Unternehmen", "Domain", "Kontakt", "E-Mail"].map(h => ( + + {h} + +
+ { + 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 }} + /> + +
+ {lead.companyName || "—"} + {!lead.isNew && ( + + vorhanden + + )} +
+
{lead.domain || "—"} + {lead.contactName + ? `${lead.contactName}${lead.contactTitle ? ` · ${lead.contactTitle}` : ""}` + : "—"} + + {lead.email ? ( + + {lead.email} + + ) : ( + + )} + + +
+
+
+ ); + })()} ); } diff --git a/components/search/LoadingCard.tsx b/components/search/LoadingCard.tsx index fa6691a..ba551a0 100644 --- a/components/search/LoadingCard.tsx +++ b/components/search/LoadingCard.tsx @@ -2,18 +2,37 @@ import { useEffect, useState } from "react"; -type Phase = "scraping" | "enriching" | "emails" | "done"; +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; - onDone: (totalFound: number) => void; - onError: () => void; + targetCount: number; + query: string; + region: string; + onDone: (leads: LeadResult[], warning?: string) => void; + onError: (message: string) => void; } const STEPS = [ @@ -39,68 +58,155 @@ function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pendi return "pending"; } -export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) { - const [jobStatus, setJobStatus] = useState({ - status: "running", - totalLeads: 0, - emailsFound: 0, - }); - const [progressWidth, setProgressWidth] = useState(3); +const PHASE_MIN: Record = { + scraping: 3, + enriching: 35, + emails: 60, + topping: 88, + done: 100, +}; - // Phase → minimum progress threshold (never go below these) - const PHASE_MIN: Record = { - scraping: 3, - enriching: 35, - emails: 60, - done: 100, - }; - const PHASE_MAX: Record = { - scraping: 34, - enriching: 59, - emails: 88, - done: 100, - }; +export function LoadingCard({ jobId, targetCount, query, region, onDone, onError }: LoadingCardProps) { + const [phase, setPhase] = useState("scraping"); + const [totalLeads, setTotalLeads] = useState(0); + const [emailsFound, setEmailsFound] = useState(0); + const [progressWidth, setProgressWidth] = useState(3); + const [isTopping, setIsTopping] = useState(false); useEffect(() => { let cancelled = false; let crawlInterval: ReturnType | null = null; let pollTimeout: ReturnType | null = null; + let currentJobId = jobId; + let toppingActive = false; + let mapsLeads: LeadResult[] = []; + + // Progress only ever moves forward + function advanceBar(to: number) { + setProgressWidth(prev => Math.max(prev, to)); + } - // Slowly creep forward — never backwards crawlInterval = setInterval(() => { if (cancelled) return; setProgressWidth(prev => { - if (prev >= 88) return prev; // hard cap while loading - return prev + 0.4; // ~0.4% per 200ms → ~2min to reach 88% + const cap = toppingActive ? 96 : 88; + if (prev >= cap) return prev; + return prev + 0.4; }); }, 200); + async function startSerpSupplement(deficit: number) { + toppingActive = true; + setIsTopping(true); + setPhase("topping"); + advanceBar(88); + + const searchQuery = region ? `${query} ${region}` : query; + const maxPages = Math.max(1, Math.ceil(deficit / 10)); + + try { + const res = await fetch("/api/jobs/serp-enrich", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: searchQuery, + maxPages, + countryCode: "de", + languageCode: "de", + filterSocial: true, + categories: ["ceo"], + enrichEmails: true, + }), + }); + if (!res.ok) throw new Error("SERP start failed"); + const data = await res.json() as { jobId: string }; + currentJobId = data.jobId; + pollTimeout = setTimeout(poll, 2500); + } catch { + // SERP failed — complete with Maps results only + if (!cancelled) { + setProgressWidth(100); + setPhase("done"); + setTimeout(() => { if (!cancelled) onDone(mapsLeads); }, 800); + } + } + } + async function poll() { if (cancelled) return; try { - const res = await fetch(`/api/jobs/${jobId}/status`); + const res = await fetch(`/api/jobs/${currentJobId}/status`); if (!res.ok) throw new Error("fetch failed"); const data = await res.json() as JobStatus; if (!cancelled) { - setJobStatus(data); - const phase = getPhase(data); - - // Advance to at least the phase minimum — never go backwards - setProgressWidth(prev => Math.max(prev, PHASE_MIN[phase])); + if (!toppingActive) { + setTotalLeads(data.totalLeads); + setEmailsFound(data.emailsFound); + const p = getPhase(data); + setPhase(p); + advanceBar(PHASE_MIN[p]); + } if (data.status === "complete") { - if (crawlInterval) clearInterval(crawlInterval); - setProgressWidth(100); - setTimeout(() => { - if (!cancelled) onDone(data.totalLeads); - }, 800); + const leads = (data.results ?? []) as LeadResult[]; + + if (!toppingActive && data.totalLeads < targetCount) { + // Maps returned fewer than requested → supplement with SERP + mapsLeads = leads; + if (crawlInterval) clearInterval(crawlInterval); + crawlInterval = setInterval(() => { + if (cancelled) return; + setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3); + }, 200); + await startSerpSupplement(targetCount - data.totalLeads); + } else { + // All done + if (crawlInterval) clearInterval(crawlInterval); + + let finalLeads: LeadResult[]; + if (toppingActive) { + // Deduplicate Maps + SERP by domain + const seenDomains = new Set(mapsLeads.map(l => l.domain).filter(Boolean)); + const newLeads = leads.filter(l => !l.domain || !seenDomains.has(l.domain)); + finalLeads = [...mapsLeads, ...newLeads]; + } else { + finalLeads = leads; + } + + // The poll often catches status=complete before observing the + // "emails" phase mid-run (Anymailfinder sets emailsFound then + // immediately sets complete in back-to-back DB writes). + // Always flash through "E-Mails suchen" so the step is visible. + if (!toppingActive) { + setPhase("emails"); + advanceBar(PHASE_MIN["emails"]); + await new Promise(r => setTimeout(r, 500)); + if (cancelled) return; + } + + setTotalLeads(finalLeads.length); + setProgressWidth(100); + setPhase("done"); + setTimeout(() => { if (!cancelled) onDone(finalLeads); }, 800); + } } else if (data.status === "failed") { if (crawlInterval) clearInterval(crawlInterval); - onError(); + const partialLeads = (data.results ?? []) as LeadResult[]; + if (toppingActive) { + // SERP failed — complete with Maps results + setProgressWidth(100); + setPhase("done"); + setTimeout(() => { if (!cancelled) onDone(mapsLeads); }, 800); + } else if (partialLeads.length > 0) { + // Job failed mid-way (e.g. Anymailfinder 402) but Maps results exist → show them + setProgressWidth(100); + setPhase("done"); + setTimeout(() => { if (!cancelled) onDone(partialLeads, data.error ?? undefined); }, 800); + } else { + onError(data.error ?? "Unbekannter Fehler"); + } } else { - // Cap crawl at phase max while in that phase - setProgressWidth(prev => Math.min(prev, PHASE_MAX[phase])); pollTimeout = setTimeout(poll, 2500); } } @@ -118,9 +224,7 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) { if (crawlInterval) clearInterval(crawlInterval); if (pollTimeout) clearTimeout(pollTimeout); }; - }, [jobId, onDone, onError]); - - const phase = getPhase(jobStatus); + }, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps return (
- Ergebnisse werden gesucht + {isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"} - {jobStatus.totalLeads > 0 || jobStatus.emailsFound > 0 - ? `${jobStatus.emailsFound} von ${jobStatus.totalLeads} gefunden` + {totalLeads > 0 || emailsFound > 0 + ? `${emailsFound} E-Mails · ${totalLeads} Unternehmen` : "Wird gestartet…"}
- {/* Progress bar */} + {/* Progress bar — only ever moves forward */}
{/* Steps */} -
+
{STEPS.map((step) => { - const state = stepState(step.id, phase); + const state = isTopping ? "done" : stepState(step.id, phase); return (
{step.label} @@ -201,15 +295,14 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
); })} + {isTopping && ( +
+
+ Ergebnisse auffüllen +
+ )}
- {/* Email note */} - {phase === "emails" && ( -

- E-Mail-Suche kann einen Moment dauern. -

- )} - {/* Warning banner */}