Files
lead-scraper/components/search/LoadingCard.tsx
2026-03-27 17:13:30 +01:00

236 lines
6.6 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" | "done";
interface JobStatus {
status: "running" | "complete" | "failed";
totalLeads: number;
emailsFound: number;
}
interface LoadingCardProps {
jobId: string;
onDone: (totalFound: number) => void;
onError: () => 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";
}
export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
const [jobStatus, setJobStatus] = useState<JobStatus>({
status: "running",
totalLeads: 0,
emailsFound: 0,
});
const [progressWidth, setProgressWidth] = useState(3);
// Phase → minimum progress threshold (never go below these)
const PHASE_MIN: Record<Phase, number> = {
scraping: 3,
enriching: 35,
emails: 60,
done: 100,
};
const PHASE_MAX: Record<Phase, number> = {
scraping: 34,
enriching: 59,
emails: 88,
done: 100,
};
useEffect(() => {
let cancelled = false;
let crawlInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
// 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%
});
}, 200);
async function poll() {
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) {
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 (data.status === "complete") {
if (crawlInterval) clearInterval(crawlInterval);
setProgressWidth(100);
setTimeout(() => {
if (!cancelled) onDone(data.totalLeads);
}, 800);
} else if (data.status === "failed") {
if (crawlInterval) clearInterval(crawlInterval);
onError();
} else {
// Cap crawl at phase max while in that phase
setProgressWidth(prev => Math.min(prev, PHASE_MAX[phase]));
pollTimeout = setTimeout(poll, 2500);
}
}
} catch {
if (!cancelled) {
pollTimeout = setTimeout(poll, 3000);
}
}
}
poll();
return () => {
cancelled = true;
if (crawlInterval) clearInterval(crawlInterval);
if (pollTimeout) clearTimeout(pollTimeout);
};
}, [jobId, onDone, onError]);
const phase = getPhase(jobStatus);
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" }}>
Ergebnisse werden gesucht
</span>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{jobStatus.totalLeads > 0 || jobStatus.emailsFound > 0
? `${jobStatus.emailsFound} von ${jobStatus.totalLeads} gefunden`
: "Wird gestartet…"}
</span>
</div>
{/* Progress bar */}
<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 }}>
{STEPS.map((step) => {
const state = 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>
);
})}
</div>
{/* Email note */}
{phase === "emails" && (
<p style={{ fontSize: 11, color: "#6b7280", fontStyle: "italic", marginBottom: 12 }}>
E-Mail-Suche kann einen Moment dauern.
</p>
)}
{/* 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>
);
}