Files
lead-scraper/components/search/LoadingCard.tsx
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

329 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);
useEffect(() => {
let cancelled = false;
let crawlInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | 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));
}
crawlInterval = setInterval(() => {
if (cancelled) return;
setProgressWidth(prev => {
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/${currentJobId}/status`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as JobStatus;
if (!cancelled) {
if (!toppingActive) {
setTotalLeads(data.totalLeads);
setEmailsFound(data.emailsFound);
const p = getPhase(data);
setPhase(p);
advanceBar(PHASE_MIN[p]);
}
if (data.status === "complete") {
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);
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 {
pollTimeout = setTimeout(poll, 2500);
}
}
} catch {
if (!cancelled) {
pollTimeout = setTimeout(poll, 3000);
}
}
}
poll();
return () => {
cancelled = true;
if (crawlInterval) clearInterval(crawlInterval);
if (pollTimeout) clearTimeout(pollTimeout);
};
}, [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>
{/* 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>
);
}