"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 = { scraping: 3, enriching: 35, emails: 60, topping: 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); const [optimizedQuery, setOptimizedQuery] = useState(null); useEffect(() => { let cancelled = false; let crawlInterval: ReturnType | null = null; let mapsPollTimeout: ReturnType | null = null; let serpPollTimeout: ReturnType | 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 (
{/* Header */}
{isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"} {totalLeads > 0 || emailsFound > 0 ? `${emailsFound} E-Mails · ${totalLeads} Unternehmen` : "Wird gestartet…"}
{/* Progress bar — only ever moves forward */}
{/* Steps */}
{STEPS.map((step) => { const state = isTopping ? "done" : stepState(step.id, phase); return (
{step.label}
); })} {isTopping && (
Ergebnisse auffüllen
)}
{/* Optimized query hint */} {isTopping && optimizedQuery && (
KI-optimierte Suche:  „{optimizedQuery}"
)} {/* Warning banner */}
⚠️ Bitte diesen Tab nicht schließen, während die Suche läuft.
); }