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

352 lines
12 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);
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>
);
}