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

@@ -18,6 +18,21 @@ export async function GET(
}); });
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 }); if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
// Find which domains already existed in vault before this job
const resultDomains = job.results
.map(r => r.domain)
.filter((d): d is string => !!d);
const preExistingDomains = new Set(
(await prisma.lead.findMany({
where: {
domain: { in: resultDomains },
sourceJobId: { not: id },
},
select: { domain: true },
})).map(l => l.domain).filter((d): d is string => !!d)
);
return NextResponse.json({ return NextResponse.json({
id: job.id, id: job.id,
type: job.type, type: job.type,
@@ -49,6 +64,7 @@ export async function GET(
address, address,
phone, phone,
createdAt: r.createdAt, createdAt: r.createdAt,
isNew: !r.domain || !preExistingDomains.has(r.domain),
}; };
}), }),
}); });

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
// Deletes vault leads that were created from the given job result IDs.
// Looks up domain from each LeadResult and removes matching Lead records.
export async function POST(req: NextRequest) {
try {
const { resultIds } = await req.json() as { resultIds: string[] };
if (!resultIds?.length) {
return NextResponse.json({ error: "No result IDs provided" }, { status: 400 });
}
// Get domains from the job results
const results = await prisma.leadResult.findMany({
where: { id: { in: resultIds } },
select: { id: true, domain: true, email: true },
});
const domains = results.map(r => r.domain).filter((d): d is string => !!d);
// Delete vault leads with matching domains
let deleted = 0;
if (domains.length > 0) {
const { count } = await prisma.lead.deleteMany({
where: { domain: { in: domains } },
});
deleted = count;
}
return NextResponse.json({ deleted });
} catch (err) {
console.error("POST /api/leads/delete-from-results error:", err);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@@ -32,24 +32,6 @@ export async function POST(req: NextRequest) {
const { jobId } = await mapsRes.json() as { jobId: string }; const { jobId } = await mapsRes.json() as { jobId: string };
// ── 2. SERP supplement (only when count > 60) — fire & forget ────────────
if (count > 60) {
const extraPages = Math.ceil((count - 60) / 10);
fetch(`${base}/api/jobs/serp-enrich`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: searchQuery,
maxPages: Math.min(extraPages, 10),
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
}).catch(() => {}); // background — don't block response
}
return NextResponse.json({ jobId }); return NextResponse.json({ jobId });
} catch (err) { } catch (err) {
console.error("POST /api/search error:", err); console.error("POST /api/search error:", err);

View File

@@ -1,18 +1,22 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useRouter } from "next/navigation"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { SearchCard } from "@/components/search/SearchCard"; import { SearchCard } from "@/components/search/SearchCard";
import { LoadingCard } from "@/components/search/LoadingCard"; import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
export default function SuchePage() { export default function SuchePage() {
const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [region, setRegion] = useState(""); const [region, setRegion] = useState("");
const [count, setCount] = useState(50); const [count, setCount] = useState(50);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [jobId, setJobId] = useState<string | null>(null); const [jobId, setJobId] = useState<string | null>(null);
const [leads, setLeads] = useState<LeadResult[]>([]);
const [searchDone, setSearchDone] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [onlyNew, setOnlyNew] = useState(false);
function handleChange(field: "query" | "region" | "count", value: string | number) { function handleChange(field: "query" | "region" | "count", value: string | number) {
if (field === "query") setQuery(value as string); if (field === "query") setQuery(value as string);
@@ -24,6 +28,10 @@ export default function SuchePage() {
if (!query.trim() || loading) return; if (!query.trim() || loading) return;
setLoading(true); setLoading(true);
setJobId(null); setJobId(null);
setLeads([]);
setSearchDone(false);
setSelected(new Set());
setOnlyNew(false);
try { try {
const res = await fetch("/api/search", { const res = await fetch("/api/search", {
method: "POST", method: "POST",
@@ -43,20 +51,44 @@ export default function SuchePage() {
} }
} }
const handleDone = useCallback((total: number) => { const handleDone = useCallback((result: LeadResult[], warning?: string) => {
setLoading(false); setLoading(false);
toast.success(`${total} Leads gefunden — Leadspeicher wird geöffnet`, { setLeads(result);
duration: 3000, setSearchDone(true);
}); if (warning) {
setTimeout(() => { toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
router.push("/leadspeicher"); } else {
}, 1500); toast.success(`${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
}, [router]); }
}, []);
const handleError = useCallback(() => { async function handleDelete(ids: string[]) {
if (!ids.length || deleting) return;
setDeleting(true);
try {
await fetch("/api/leads/delete-from-results", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resultIds: ids }),
});
setLeads(prev => prev.filter(l => !ids.includes(l.id)));
setSelected(prev => {
const next = new Set(prev);
ids.forEach(id => next.delete(id));
return next;
});
toast.success(`${ids.length} Lead${ids.length > 1 ? "s" : ""} gelöscht`);
} catch {
toast.error("Löschen fehlgeschlagen");
} finally {
setDeleting(false);
}
}
const handleError = useCallback((message: string) => {
setLoading(false); setLoading(false);
setJobId(null); setJobId(null);
toast.error("Suche fehlgeschlagen. Bitte prüfe deine API-Einstellungen."); toast.error(`Suche fehlgeschlagen: ${message}`);
}, []); }, []);
return ( return (
@@ -99,10 +131,243 @@ export default function SuchePage() {
{loading && jobId && ( {loading && jobId && (
<LoadingCard <LoadingCard
jobId={jobId} jobId={jobId}
targetCount={count}
query={query}
region={region}
onDone={handleDone} onDone={handleDone}
onError={handleError} onError={handleError}
/> />
)} )}
{/* Results */}
{searchDone && leads.length > 0 && (() => {
const newCount = leads.filter(l => l.isNew).length;
const visibleLeads = onlyNew ? leads.filter(l => l.isNew) : leads;
const allVisibleSelected = visibleLeads.length > 0 && visibleLeads.every(l => selected.has(l.id));
const someVisibleSelected = visibleLeads.some(l => selected.has(l.id));
const selectedVisible = visibleLeads.filter(l => selected.has(l.id));
return (
<div
style={{
marginTop: 16,
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 12,
overflow: "hidden",
}}
>
<style>{`
.del-btn:hover { opacity: 1 !important; }
.lead-row:hover { background: rgba(255,255,255,0.02); }
.lead-row:hover .row-del { opacity: 1 !important; }
.filter-pill { transition: background 0.15s, color 0.15s; }
.filter-pill:hover { background: rgba(59,130,246,0.15) !important; }
`}</style>
{/* Header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #1e1e2e",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Select-all checkbox */}
<input
type="checkbox"
checked={allVisibleSelected}
ref={el => { if (el) el.indeterminate = someVisibleSelected && !allVisibleSelected; }}
onChange={e => {
if (e.target.checked) {
setSelected(prev => {
const next = new Set(prev);
visibleLeads.forEach(l => next.add(l.id));
return next;
});
} else {
setSelected(prev => {
const next = new Set(prev);
visibleLeads.forEach(l => next.delete(l.id));
return next;
});
}
}}
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 14, height: 14 }}
/>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
{selectedVisible.length > 0
? `${selectedVisible.length} ausgewählt`
: `${visibleLeads.length} Leads`}
</span>
{/* Nur neue Filter */}
<button
className="filter-pill"
onClick={() => setOnlyNew(v => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 5,
background: onlyNew ? "rgba(34,197,94,0.15)" : "rgba(255,255,255,0.05)",
border: `1px solid ${onlyNew ? "rgba(34,197,94,0.4)" : "#2e2e3e"}`,
borderRadius: 20,
padding: "3px 10px",
fontSize: 11,
color: onlyNew ? "#22c55e" : "#9ca3af",
cursor: "pointer",
}}
>
<span style={{
width: 6, height: 6, borderRadius: 3,
background: onlyNew ? "#22c55e" : "#6b7280",
display: "inline-block",
}} />
Nur neue ({newCount})
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{selectedVisible.length > 0 && (
<button
className="del-btn"
onClick={() => handleDelete(selectedVisible.map(l => l.id))}
disabled={deleting}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "rgba(239,68,68,0.12)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 7,
padding: "5px 12px",
fontSize: 12,
color: "#ef4444",
cursor: deleting ? "not-allowed" : "pointer",
opacity: deleting ? 0.5 : 0.85,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
{selectedVisible.length} löschen
</button>
)}
<Link href="/leadspeicher" style={{ fontSize: 12, color: "#3b82f6", textDecoration: "none" }}>
Im Leadspeicher
</Link>
</div>
</div>
{/* Table */}
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: "1px solid #1e1e2e" }}>
<th style={{ width: 40, padding: "8px 16px" }} />
{["Unternehmen", "Domain", "Kontakt", "E-Mail"].map(h => (
<th key={h} style={{ padding: "8px 16px", textAlign: "left", color: "#6b7280", fontWeight: 500 }}>
{h}
</th>
))}
<th style={{ width: 40, padding: "8px 16px" }} />
</tr>
</thead>
<tbody>
{visibleLeads.map((lead, i) => {
const isSelected = selected.has(lead.id);
return (
<tr
key={lead.id}
className="lead-row"
style={{
borderBottom: i < visibleLeads.length - 1 ? "1px solid #1a1a2a" : "none",
background: isSelected ? "rgba(59,130,246,0.06)" : undefined,
}}
>
<td style={{ padding: "9px 16px", textAlign: "center" }}>
<input
type="checkbox"
checked={isSelected}
onChange={e => {
setSelected(prev => {
const next = new Set(prev);
e.target.checked ? next.add(lead.id) : next.delete(lead.id);
return next;
});
}}
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 13, height: 13 }}
/>
</td>
<td style={{ padding: "9px 16px", color: "#ffffff" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{lead.companyName || "—"}
{!lead.isNew && (
<span style={{
fontSize: 10,
color: "#6b7280",
background: "#1e1e2e",
borderRadius: 4,
padding: "1px 6px",
whiteSpace: "nowrap",
}}>
vorhanden
</span>
)}
</div>
</td>
<td style={{ padding: "9px 16px", color: "#6b7280" }}>{lead.domain || "—"}</td>
<td style={{ padding: "9px 16px", color: "#d1d5db" }}>
{lead.contactName
? `${lead.contactName}${lead.contactTitle ? ` · ${lead.contactTitle}` : ""}`
: "—"}
</td>
<td style={{ padding: "9px 16px" }}>
{lead.email ? (
<a href={`mailto:${lead.email}`} style={{ color: "#3b82f6", textDecoration: "none" }}>
{lead.email}
</a>
) : (
<span style={{ color: "#4b5563" }}></span>
)}
</td>
<td style={{ padding: "9px 12px", textAlign: "center" }}>
<button
className="row-del"
onClick={() => handleDelete([lead.id])}
disabled={deleting}
title="Lead löschen"
style={{
background: "none",
border: "none",
cursor: deleting ? "not-allowed" : "pointer",
color: "#6b7280",
opacity: 0,
padding: 4,
borderRadius: 4,
display: "flex",
alignItems: "center",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})()}
</div> </div>
); );
} }

View File

@@ -2,18 +2,37 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type Phase = "scraping" | "enriching" | "emails" | "done"; type Phase = "scraping" | "enriching" | "emails" | "topping" | "done";
interface JobStatus { interface JobStatus {
status: "running" | "complete" | "failed"; status: "running" | "complete" | "failed";
totalLeads: number; totalLeads: number;
emailsFound: 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 { interface LoadingCardProps {
jobId: string; jobId: string;
onDone: (totalFound: number) => void; targetCount: number;
onError: () => void; query: string;
region: string;
onDone: (leads: LeadResult[], warning?: string) => void;
onError: (message: string) => void;
} }
const STEPS = [ const STEPS = [
@@ -39,68 +58,155 @@ function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pendi
return "pending"; return "pending";
} }
export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) { const PHASE_MIN: Record<Phase, number> = {
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, scraping: 3,
enriching: 35, enriching: 35,
emails: 60, emails: 60,
topping: 88,
done: 100, done: 100,
}; };
const PHASE_MAX: Record<Phase, number> = {
scraping: 34, export function LoadingCard({ jobId, targetCount, query, region, onDone, onError }: LoadingCardProps) {
enriching: 59, const [phase, setPhase] = useState<Phase>("scraping");
emails: 88, const [totalLeads, setTotalLeads] = useState(0);
done: 100, const [emailsFound, setEmailsFound] = useState(0);
}; const [progressWidth, setProgressWidth] = useState(3);
const [isTopping, setIsTopping] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let crawlInterval: ReturnType<typeof setInterval> | null = null; let crawlInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | 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(() => { crawlInterval = setInterval(() => {
if (cancelled) return; if (cancelled) return;
setProgressWidth(prev => { setProgressWidth(prev => {
if (prev >= 88) return prev; // hard cap while loading const cap = toppingActive ? 96 : 88;
return prev + 0.4; // ~0.4% per 200ms → ~2min to reach 88% if (prev >= cap) return prev;
return prev + 0.4;
}); });
}, 200); }, 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() { async function poll() {
if (cancelled) return; if (cancelled) return;
try { 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"); if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as JobStatus; const data = await res.json() as JobStatus;
if (!cancelled) { if (!cancelled) {
setJobStatus(data); if (!toppingActive) {
const phase = getPhase(data); setTotalLeads(data.totalLeads);
setEmailsFound(data.emailsFound);
// Advance to at least the phase minimum — never go backwards const p = getPhase(data);
setProgressWidth(prev => Math.max(prev, PHASE_MIN[phase])); setPhase(p);
advanceBar(PHASE_MIN[p]);
}
if (data.status === "complete") { if (data.status === "complete") {
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); 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); setProgressWidth(100);
setTimeout(() => { setPhase("done");
if (!cancelled) onDone(data.totalLeads); setTimeout(() => { if (!cancelled) onDone(finalLeads); }, 800);
}, 800); }
} else if (data.status === "failed") { } else if (data.status === "failed") {
if (crawlInterval) clearInterval(crawlInterval); 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 { } else {
// Cap crawl at phase max while in that phase
setProgressWidth(prev => Math.min(prev, PHASE_MAX[phase]));
pollTimeout = setTimeout(poll, 2500); pollTimeout = setTimeout(poll, 2500);
} }
} }
@@ -118,9 +224,7 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
if (crawlInterval) clearInterval(crawlInterval); if (crawlInterval) clearInterval(crawlInterval);
if (pollTimeout) clearTimeout(pollTimeout); if (pollTimeout) clearTimeout(pollTimeout);
}; };
}, [jobId, onDone, onError]); }, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
const phase = getPhase(jobStatus);
return ( return (
<div <div
@@ -135,16 +239,16 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
{/* Header */} {/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}> <span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
Ergebnisse werden gesucht {isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"}
</span> </span>
<span style={{ fontSize: 12, color: "#9ca3af" }}> <span style={{ fontSize: 12, color: "#9ca3af" }}>
{jobStatus.totalLeads > 0 || jobStatus.emailsFound > 0 {totalLeads > 0 || emailsFound > 0
? `${jobStatus.emailsFound} von ${jobStatus.totalLeads} gefunden` ? `${emailsFound} E-Mails · ${totalLeads} Unternehmen`
: "Wird gestartet…"} : "Wird gestartet…"}
</span> </span>
</div> </div>
{/* Progress bar */} {/* Progress bar — only ever moves forward */}
<div <div
style={{ style={{
height: 5, height: 5,
@@ -166,9 +270,9 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
</div> </div>
{/* Steps */} {/* Steps */}
<div style={{ display: "flex", gap: 20, marginBottom: 16 }}> <div style={{ display: "flex", gap: 20, marginBottom: 16, flexWrap: "wrap" }}>
{STEPS.map((step) => { {STEPS.map((step) => {
const state = stepState(step.id, phase); const state = isTopping ? "done" : stepState(step.id, phase);
return ( return (
<div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}> <div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div <div
@@ -176,24 +280,14 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: 3, borderRadius: 3,
background: background: state === "done" ? "#22c55e" : state === "active" ? "#3b82f6" : "#2e2e3e",
state === "done"
? "#22c55e"
: state === "active"
? "#3b82f6"
: "#2e2e3e",
animation: state === "active" ? "pulse 1.5s infinite" : "none", animation: state === "active" ? "pulse 1.5s infinite" : "none",
}} }}
/> />
<span <span
style={{ style={{
fontSize: 12, fontSize: 12,
color: color: state === "done" ? "#22c55e" : state === "active" ? "#ffffff" : "#6b7280",
state === "done"
? "#22c55e"
: state === "active"
? "#ffffff"
: "#6b7280",
}} }}
> >
{step.label} {step.label}
@@ -201,14 +295,13 @@ export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
</div> </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>
)} )}
</div>
{/* Warning banner */} {/* Warning banner */}
<div <div

View File

@@ -35,20 +35,11 @@ export async function sinkLeadToVault(
const email = lead.email || null; const email = lead.email || null;
if (domain) { if (domain) {
// Check for exact duplicate (same domain + same email) // Strict domain dedup: one lead per domain
if (email) { const existing = await prisma.lead.findFirst({ where: { domain } });
const exact = await prisma.lead.findFirst({
where: { domain, email },
});
if (exact) return exact.id; // already exists, skip
}
// Check if same domain exists without email and we now have one if (existing) {
const existing = await prisma.lead.findFirst({ if (email && !existing.email) {
where: { domain, email: null },
});
if (existing && email) {
// Upgrade: fill in email + other missing fields // Upgrade: fill in email + other missing fields
await prisma.lead.update({ await prisma.lead.update({
where: { id: existing.id }, where: { id: existing.id },
@@ -61,7 +52,8 @@ export async function sinkLeadToVault(
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
return existing.id; }
return existing.id; // domain already exists → skip or upgraded
} }
} }
@@ -107,13 +99,12 @@ export async function sinkLeadsToVault(
const email = lead.email || null; const email = lead.email || null;
if (domain) { if (domain) {
if (email) { // Strict domain dedup: one lead per domain
const exact = await prisma.lead.findFirst({ where: { domain, email } }); const existing = await prisma.lead.findFirst({ where: { domain } });
if (exact) { skipped++; continue; }
}
const existing = await prisma.lead.findFirst({ where: { domain, email: null } }); if (existing) {
if (existing && email) { if (email && !existing.email) {
// Upgrade: existing has no email, new one does → fill it in
await prisma.lead.update({ await prisma.lead.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
@@ -126,6 +117,9 @@ export async function sinkLeadsToVault(
}, },
}); });
updated++; updated++;
} else {
skipped++; // domain already exists → skip
}
continue; continue;
} }
} }

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "leadflow", "name": "onyva-leads",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "leadflow", "name": "onyva-leads",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",