feat: Kundensuche – Progressbar, SERP-Supplement, Dedup, Löschen, Neu-Filter

- Progressbar geht nie mehr rückwärts (Math.min-Cap entfernt)
- E-Mails-suchen-Phase wird immer kurz angezeigt bevor Fertig
- SERP-Supplement startet automatisch wenn Maps < Zielanzahl liefert
- Suchergebnisse bleiben nach Abschluss sichtbar (kein Redirect)
- Dedup in leadVault strikt nach Domain (verhindert Duplikate)
- isNew-Flag pro Result (Batch-Query gegen bestehende Vault-Domains)
- Nur-neue-Filter + vorhanden-Badge in Suchergebnissen
- Einzeln und Bulk löschen aus Suchergebnissen + Leadspeicher
- Fehlermeldung zeigt echten API-Fehler (z.B. 402 Anymailfinder)
- SERP-Supplement aus /api/search entfernt (LoadingCard übernimmt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TimoUttenweiler
2026-04-01 10:25:43 +02:00
parent c232f0cb79
commit aa6707b8bc
7 changed files with 531 additions and 145 deletions

View File

@@ -2,18 +2,37 @@
import { useEffect, useState } from "react";
type Phase = "scraping" | "enriching" | "emails" | "done";
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;
onDone: (totalFound: number) => void;
onError: () => void;
targetCount: number;
query: string;
region: string;
onDone: (leads: LeadResult[], warning?: string) => void;
onError: (message: string) => void;
}
const STEPS = [
@@ -39,68 +58,155 @@ function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pendi
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);
const PHASE_MIN: Record<Phase, number> = {
scraping: 3,
enriching: 35,
emails: 60,
topping: 88,
done: 100,
};
// 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,
};
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);
useEffect(() => {
let cancelled = false;
let crawlInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | 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));
}
// 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%
const cap = toppingActive ? 96 : 88;
if (prev >= cap) return prev;
return prev + 0.4;
});
}, 200);
async function startSerpSupplement(deficit: number) {
toppingActive = true;
setIsTopping(true);
setPhase("topping");
advanceBar(88);
const searchQuery = region ? `${query} ${region}` : query;
const maxPages = Math.max(1, Math.ceil(deficit / 10));
try {
const res = await fetch("/api/jobs/serp-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: searchQuery,
maxPages,
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
});
if (!res.ok) throw new Error("SERP start failed");
const data = await res.json() as { jobId: string };
currentJobId = data.jobId;
pollTimeout = setTimeout(poll, 2500);
} catch {
// SERP 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/${jobId}/status`);
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) {
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 (!toppingActive) {
setTotalLeads(data.totalLeads);
setEmailsFound(data.emailsFound);
const p = getPhase(data);
setPhase(p);
advanceBar(PHASE_MIN[p]);
}
if (data.status === "complete") {
if (crawlInterval) clearInterval(crawlInterval);
setProgressWidth(100);
setTimeout(() => {
if (!cancelled) onDone(data.totalLeads);
}, 800);
const leads = (data.results ?? []) as LeadResult[];
if (!toppingActive && data.totalLeads < targetCount) {
// Maps returned fewer than requested → supplement with SERP
mapsLeads = leads;
if (crawlInterval) clearInterval(crawlInterval);
crawlInterval = setInterval(() => {
if (cancelled) return;
setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3);
}, 200);
await startSerpSupplement(targetCount - 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);
onError();
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 {
// Cap crawl at phase max while in that phase
setProgressWidth(prev => Math.min(prev, PHASE_MAX[phase]));
pollTimeout = setTimeout(poll, 2500);
}
}
@@ -118,9 +224,7 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
if (crawlInterval) clearInterval(crawlInterval);
if (pollTimeout) clearTimeout(pollTimeout);
};
}, [jobId, onDone, onError]);
const phase = getPhase(jobStatus);
}, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
@@ -135,16 +239,16 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
Ergebnisse werden gesucht
{isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"}
</span>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{jobStatus.totalLeads > 0 || jobStatus.emailsFound > 0
? `${jobStatus.emailsFound} von ${jobStatus.totalLeads} gefunden`
{totalLeads > 0 || emailsFound > 0
? `${emailsFound} E-Mails · ${totalLeads} Unternehmen`
: "Wird gestartet…"}
</span>
</div>
{/* Progress bar */}
{/* Progress bar — only ever moves forward */}
<div
style={{
height: 5,
@@ -166,9 +270,9 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
</div>
{/* Steps */}
<div style={{ display: "flex", gap: 20, marginBottom: 16 }}>
<div style={{ display: "flex", gap: 20, marginBottom: 16, flexWrap: "wrap" }}>
{STEPS.map((step) => {
const state = stepState(step.id, phase);
const state = isTopping ? "done" : stepState(step.id, phase);
return (
<div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div
@@ -176,24 +280,14 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
width: 6,
height: 6,
borderRadius: 3,
background:
state === "done"
? "#22c55e"
: state === "active"
? "#3b82f6"
: "#2e2e3e",
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",
color: state === "done" ? "#22c55e" : state === "active" ? "#ffffff" : "#6b7280",
}}
>
{step.label}
@@ -201,15 +295,14 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
</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>
{/* 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={{