"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 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)); } 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(foundCount: number) { toppingActive = true; setIsTopping(true); setPhase("topping"); advanceBar(88); 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 }; currentJobId = data.jobId; if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery); pollTimeout = setTimeout(poll, 2500); } catch { // Supplement 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 optimized SERP mapsLeads = leads; if (crawlInterval) clearInterval(crawlInterval); crawlInterval = setInterval(() => { if (cancelled) return; setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3); }, 200); await startSerpSupplement(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 (
{/* 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.
); }