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 });
// 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({
id: job.id,
type: job.type,
@@ -49,6 +64,7 @@ export async function GET(
address,
phone,
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 };
// ── 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 });
} catch (err) {
console.error("POST /api/search error:", err);

View File

@@ -1,18 +1,22 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { SearchCard } from "@/components/search/SearchCard";
import { LoadingCard } from "@/components/search/LoadingCard";
import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
export default function SuchePage() {
const router = useRouter();
const [query, setQuery] = useState("");
const [region, setRegion] = useState("");
const [count, setCount] = useState(50);
const [loading, setLoading] = useState(false);
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) {
if (field === "query") setQuery(value as string);
@@ -24,6 +28,10 @@ export default function SuchePage() {
if (!query.trim() || loading) return;
setLoading(true);
setJobId(null);
setLeads([]);
setSearchDone(false);
setSelected(new Set());
setOnlyNew(false);
try {
const res = await fetch("/api/search", {
method: "POST",
@@ -43,20 +51,44 @@ export default function SuchePage() {
}
}
const handleDone = useCallback((total: number) => {
const handleDone = useCallback((result: LeadResult[], warning?: string) => {
setLoading(false);
toast.success(`${total} Leads gefunden — Leadspeicher wird geöffnet`, {
duration: 3000,
});
setTimeout(() => {
router.push("/leadspeicher");
}, 1500);
}, [router]);
setLeads(result);
setSearchDone(true);
if (warning) {
toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
} else {
toast.success(`${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
}
}, []);
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);
setJobId(null);
toast.error("Suche fehlgeschlagen. Bitte prüfe deine API-Einstellungen.");
toast.error(`Suche fehlgeschlagen: ${message}`);
}, []);
return (
@@ -99,10 +131,243 @@ export default function SuchePage() {
{loading && jobId && (
<LoadingCard
jobId={jobId}
targetCount={count}
query={query}
region={region}
onDone={handleDone}
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>
);
}