Files
lead-scraper/components/search/LoadingCard.tsx
Timo Uttenweiler 60073b97c9 feat: OnyvaLeads customer UI — Suche + Leadspeicher
- New Topbar: logo, 2-tab pill switcher, live "Neu" badge
- /suche page: SearchCard, LoadingCard, ExamplePills
- /leadspeicher page: full leads table with filters, pagination
- StatusBadge, StatusPopover, LeadSidePanel, BulkActionBar
- POST /api/search: unified search entry point → serp-enrich
- Remove Sidebar + old TopBar from layout
- Title: OnyvaLeads, redirect / → /suche

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:48:05 +01:00

221 lines
6.2 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(40);
useEffect(() => {
let cancelled = false;
let pendulumInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
// Pendulum animation while running
let goingUp = true;
pendulumInterval = setInterval(() => {
if (cancelled) return;
setProgressWidth((prev) => {
if (goingUp) {
if (prev >= 85) { goingUp = false; return 84; }
return prev + 1;
} else {
if (prev <= 40) { goingUp = true; return 41; }
return prev - 1;
}
});
}, 120);
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);
if (data.status === "complete") {
if (pendulumInterval) clearInterval(pendulumInterval);
setProgressWidth(100);
setTimeout(() => {
if (!cancelled) onDone(data.totalLeads);
}, 800);
} else if (data.status === "failed") {
if (pendulumInterval) clearInterval(pendulumInterval);
onError();
} else {
pollTimeout = setTimeout(poll, 2500);
}
}
} catch {
if (!cancelled) {
pollTimeout = setTimeout(poll, 3000);
}
}
}
poll();
return () => {
cancelled = true;
if (pendulumInterval) clearInterval(pendulumInterval);
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>
);
}