Files
lead-scraper/app/maps/page.tsx
Timo Uttenweiler 115cdacd08 UI improvements: Leadspeicher, Maps enrichment, exports
- Rename LeadVault → Leadspeicher throughout (sidebar, topbar, page)
- SidePanel: full lead detail view with contact, source, tags (read-only), Google Maps link for address
- Tags: kontaktiert stored as tag (toggleable), favorit tag toggle
- Remove Status column, StatusBadge dropdown, Priority feature
- Remove Aktualisieren button from Leadspeicher
- Bulk actions: remove status dropdown
- Export: LeadVault Excel-only, clean columns, freeze row + autofilter
- Export dropdown: click-based (fix overflow-hidden clipping)
- ExportButtons: remove CSV, Excel only everywhere
- Maps page: post-search Anymailfinder enrichment button
- ProgressCard: "Suche läuft..." instead of "Warte auf Anymailfinder-Server..."
- Quick SERP renamed to "Schnell neue Suche"
- Results page: Excel export, always-enabled download button
- Anymailfinder: fix bulk field names, array-of-arrays format
- Apify: fix countryCode lowercase
- API: sourceTerm search, contacted/favorite tag filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:12:31 +01:00

574 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ProgressCard } from "@/components/shared/ProgressCard";
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
import { ExportButtons } from "@/components/shared/ExportButtons";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
import { MapPin, ChevronRight, Plus, X, Info } from "lucide-react";
import { useAppStore } from "@/lib/store";
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
import type { ExportRow } from "@/lib/utils/csv";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string; recommended?: boolean }[] = [
{ value: "ceo", label: "CEO / Owner / President / Founder", recommended: true },
{ value: "operations", label: "COO" },
{ value: "engineering", label: "CTO" },
{ value: "marketing", label: "CMO" },
{ value: "finance", label: "CFO" },
{ value: "sales", label: "Vertriebsleiter" },
];
const RESULTS_OPTIONS = [
{ value: "20", label: "20 per query" },
{ value: "40", label: "40 per query" },
{ value: "60", label: "60 per query (max)" },
];
const KEYWORD_PRESETS = [
"Solaranlage Installateur", "Dachdecker", "Elektriker",
"Heizungsbauer", "Sanitär", "Steuerberater", "Rechtsanwalt",
"Zahnarzt", "Physiotherapeut", "Immobilienmakler",
];
const GERMAN_REGIONS = [
"Bayern", "Baden-Württemberg", "Nordrhein-Westfalen", "Hessen",
"Niedersachsen", "Sachsen", "Rheinland-Pfalz", "Brandenburg",
"Berlin", "Hamburg", "München", "Frankfurt", "Stuttgart",
"Düsseldorf", "Köln", "Leipzig", "Dresden",
];
type Stage = "idle" | "running" | "done" | "failed";
export default function MapsPage() {
const [keywords, setKeywords] = useState<string[]>(["Solaranlage Installateur"]);
const [keywordInput, setKeywordInput] = useState("");
const [regions, setRegions] = useState<string[]>(["Bayern", "Baden-Württemberg"]);
const [regionInput, setRegionInput] = useState("");
const [maxResults, setMaxResults] = useState("60");
const [enrichEmails, setEnrichEmails] = useState(true);
const [category, setCategory] = useState<DecisionMakerCategory>("ceo");
const [stage, setStage] = useState<Stage>("idle");
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
const [results, setResults] = useState<ResultRow[]>([]);
const [enrichStage, setEnrichStage] = useState<Stage>("idle");
const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 });
const { addJob, updateJob, removeJob } = useAppStore();
// Build queries: every keyword × every region
const queries = keywords.length === 0 ? [] :
regions.length > 0
? keywords.flatMap(k => regions.map(r => `${k} ${r}`))
: keywords;
const addKeyword = (k: string) => {
const trimmed = k.trim();
if (trimmed && !keywords.includes(trimmed)) setKeywords(prev => [...prev, trimmed]);
setKeywordInput("");
};
const removeKeyword = (k: string) => setKeywords(prev => prev.filter(x => x !== k));
const addRegion = (r: string) => {
const trimmed = r.trim();
if (trimmed && !regions.includes(trimmed)) {
setRegions(prev => [...prev, trimmed]);
}
setRegionInput("");
};
const removeRegion = (r: string) => setRegions(prev => prev.filter(x => x !== r));
const startJob = async () => {
if (!keywords.length) return toast.error("Mindestens einen Suchbegriff eingeben");
setStage("running");
setResults([]);
setProgress({ current: 0, total: queries.length, phase: "Searching Google Maps..." });
try {
const res = await fetch("/api/jobs/maps-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
queries,
maxResultsPerQuery: Number(maxResults),
languageCode: "de",
categories: [category],
enrichEmails,
}),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
setJobId(data.jobId);
addJob({ id: data.jobId, type: "maps", status: "running", progress: 0, total: queries.length * Number(maxResults) });
pollJob(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start");
setStage("failed");
}
};
const pollJob = (id: string) => {
let phase = "Searching Google Maps...";
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/jobs/${id}/status`);
const data = await res.json() as {
status: string; totalLeads: number; emailsFound: number; results: ResultRow[]; error?: string;
};
if (data.totalLeads > 0 && data.emailsFound === 0 && enrichEmails) {
phase = "Anreicherung mit Anymailfinder...";
}
if (data.emailsFound > 0) {
phase = `${data.emailsFound} E-Mails gefunden...`;
}
setProgress({
current: enrichEmails ? data.emailsFound : data.totalLeads,
total: enrichEmails ? data.totalLeads : queries.length * Number(maxResults),
phase,
});
if (data.results?.length) setResults(data.results);
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
removeJob(id);
setResults(data.results || []);
if (data.status === "complete") {
setStage("done");
const msg = enrichEmails
? `Fertig! ${data.totalLeads} Unternehmen gefunden, ${data.emailsFound} E-Mails angereichert`
: `Fertig! ${data.totalLeads} Unternehmen gefunden`;
toast.success(msg);
} else {
setStage("failed");
const errMsg = data.error || "Unbekannter Fehler";
toast.error(`Job fehlgeschlagen: ${errMsg}`);
}
}
} catch {
clearInterval(interval);
setStage("failed");
}
}, 2000);
};
const startEnrichment = async () => {
const companies = results
.filter(r => r.domain)
.map(r => ({ name: r.companyName || "", domain: r.domain || "" }));
if (!companies.length) return toast.error("Keine Domains in den Ergebnissen");
setEnrichStage("running");
setEnrichProgress({ current: 0, total: companies.length });
try {
const res = await fetch("/api/jobs/airscale-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ companies, categories: [category] }),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
pollEnrichJob(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Anreicherung konnte nicht gestartet werden");
setEnrichStage("failed");
}
};
const pollEnrichJob = (id: string) => {
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/jobs/${id}/status`);
const data = await res.json() as {
status: string; totalLeads: number; emailsFound: number; results: ResultRow[]; error?: string;
};
setEnrichProgress({ current: data.emailsFound, total: data.totalLeads });
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
removeJob(id);
setEnrichStage(data.status === "complete" ? "done" : "failed");
if (data.status === "complete") {
const emailMap = new Map(
(data.results || []).filter(r => r.email).map(r => [r.domain, r])
);
setResults(prev => prev.map(r => {
const enriched = r.domain ? emailMap.get(r.domain) : undefined;
return enriched ? { ...r, email: enriched.email, contactName: enriched.contactName, contactTitle: enriched.contactTitle } : r;
}));
toast.success(`${data.emailsFound} Entscheider-Emails gefunden`);
} else {
toast.error(`Anreicherung fehlgeschlagen: ${data.error || "Unbekannter Fehler"}`);
}
}
} catch {
clearInterval(interval);
setEnrichStage("failed");
}
}, 2000);
};
const exportRows: ExportRow[] = results.map(r => ({
company_name: r.companyName,
domain: r.domain,
contact_name: r.contactName,
contact_title: r.contactTitle,
email: r.email,
}));
const emailsFound = results.filter(r => r.email).length;
const totalExpected = queries.length * Number(maxResults);
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-green-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative">
<div className="flex items-center gap-2 text-sm text-green-400 mb-2">
<MapPin className="w-4 h-4" />
<span>Tab 4</span>
<ChevronRight className="w-3 h-3" />
<span>Google Maps</span>
</div>
<h1 className="text-2xl font-bold text-white">Google Maps Email</h1>
<p className="text-gray-400 mt-1 text-sm">
Finde lokale Unternehmen über Google Maps und bereichere sie mit Entscheider-Emails.
</p>
</div>
</div>
{/* Info banner */}
<div className="flex items-start gap-3 bg-blue-500/5 border border-blue-500/20 rounded-xl px-4 py-3">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-300">
Nutzt die Google Maps Places API (New). Max. 60 Ergebnisse pro Suchanfrage.
Füge mehrere Regionen hinzu um mehr Ergebnisse zu erhalten.
<span className="text-gray-400 ml-1">
$200 Free Credit/Monat ~6.000 kostenlose Searches.
</span>
</p>
</div>
{/* Step 1: Search config */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-green-500/20 text-green-400 text-xs flex items-center justify-center font-bold">1</span>
Suchanfrage konfigurieren
</h2>
{/* Keywords */}
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">
Suchbegriffe
<span className="text-gray-500 font-normal ml-2"> mehrere möglich</span>
</Label>
<div className="min-h-[44px] flex flex-wrap gap-2 p-2.5 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg focus-within:border-green-500 transition-colors mb-2">
{keywords.map(k => (
<span key={k} className="flex items-center gap-1 bg-green-500/15 text-green-300 border border-green-500/25 rounded-md px-2.5 py-0.5 text-sm">
{k}
<button onClick={() => removeKeyword(k)} className="hover:text-white transition-colors">
<X className="w-3 h-3" />
</button>
</span>
))}
<input
className="flex-1 min-w-[160px] bg-transparent text-sm text-white outline-none placeholder:text-gray-600"
placeholder="Begriff eingeben, Enter drücken..."
value={keywordInput}
onChange={e => setKeywordInput(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addKeyword(keywordInput); }
}}
onBlur={() => keywordInput && addKeyword(keywordInput)}
/>
</div>
{/* Keyword presets */}
<div className="flex flex-wrap gap-1.5">
{KEYWORD_PRESETS.filter(k => !keywords.includes(k)).map(k => (
<button
key={k}
onClick={() => addKeyword(k)}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded border border-[#2e2e3e] text-gray-500 hover:border-green-500/30 hover:text-gray-300 transition-all"
>
<Plus className="w-2.5 h-2.5" /> {k}
</button>
))}
</div>
</div>
{/* Regions */}
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">
Regionen / Städte
<span className="text-gray-500 font-normal ml-2"> je Region eine eigene Suchanfrage</span>
</Label>
{/* Region chips */}
<div className="min-h-[44px] flex flex-wrap gap-2 p-2.5 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg focus-within:border-green-500 transition-colors mb-2">
{regions.map(r => (
<span
key={r}
className="flex items-center gap-1 bg-green-500/15 text-green-300 border border-green-500/25 rounded-md px-2.5 py-0.5 text-sm"
>
{r}
<button onClick={() => removeRegion(r)} className="hover:text-white transition-colors">
<X className="w-3 h-3" />
</button>
</span>
))}
<input
className="flex-1 min-w-[120px] bg-transparent text-sm text-white outline-none placeholder:text-gray-600"
placeholder="Region eingeben, Enter drücken..."
value={regionInput}
onChange={e => setRegionInput(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addRegion(regionInput); }
}}
onBlur={() => regionInput && addRegion(regionInput)}
/>
</div>
{/* Region presets */}
<div className="flex flex-wrap gap-1.5">
{GERMAN_REGIONS.filter(r => !regions.includes(r)).map(r => (
<button
key={r}
onClick={() => addRegion(r)}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded border border-[#2e2e3e] text-gray-500 hover:border-green-500/30 hover:text-gray-300 transition-all"
>
<Plus className="w-2.5 h-2.5" /> {r}
</button>
))}
</div>
</div>
{/* Max results + language */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Ergebnisse pro Region</Label>
<Select value={maxResults} onValueChange={v => setMaxResults(v ?? "60")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{RESULTS_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value} className="text-gray-300">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-1">
<p className="text-sm text-gray-500">
= bis zu{" "}
<span className="text-white font-semibold">{totalExpected.toLocaleString()}</span>{" "}
Ergebnisse total ({queries.length} {queries.length === 1 ? "Query" : "Queries"})
</p>
</div>
</div>
{/* Preview queries */}
{queries.length > 0 && (
<div className="bg-[#0d0d18] rounded-lg border border-[#1e1e2e] p-3">
<p className="text-xs text-gray-500 mb-2">Suchanfragen die ausgeführt werden:</p>
<div className="space-y-1">
{queries.slice(0, 5).map((q, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="w-4 h-4 rounded bg-green-500/20 text-green-400 flex items-center justify-center font-mono text-[10px]">{i+1}</span>
<span className="text-gray-300 font-mono">"{q}"</span>
</div>
))}
{queries.length > 5 && (
<p className="text-xs text-gray-600 pl-6">+{queries.length - 5} weitere...</p>
)}
</div>
</div>
)}
</Card>
{/* Step 2: Email enrichment */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-green-500/20 text-green-400 text-xs flex items-center justify-center font-bold">2</span>
Email Enrichment
</h2>
<div className="flex items-start gap-3">
<Checkbox
id="enrichEmails"
checked={enrichEmails}
onCheckedChange={v => setEnrichEmails(!!v)}
className="border-[#2e2e3e] mt-0.5"
/>
<div>
<label htmlFor="enrichEmails" className="text-sm text-gray-300 cursor-pointer font-medium">
Direkt mit Anymailfinder anreichern
</label>
<p className="text-xs text-gray-500 mt-0.5">
Entscheider-Emails für alle gefundenen Domains suchen (2 Credits/gültige Email)
</p>
</div>
</div>
{enrichEmails && (
<div className="flex flex-wrap gap-2 pl-6">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setCategory(opt.value)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
category === opt.value
? "bg-green-500/20 text-green-300 border-green-500/40"
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-green-500/30"
}`}
>
{opt.label}
{opt.recommended && (
<span className="text-[10px] bg-green-500/30 text-green-300 px-1.5 py-0.5 rounded font-semibold">Empfohlen</span>
)}
</button>
))}
</div>
)}
</Card>
{/* Step 3: Run */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-green-500/20 text-green-400 text-xs flex items-center justify-center font-bold">3</span>
Starten
</h2>
{stage === "idle" || stage === "failed" ? (
<Button
onClick={startJob}
disabled={!keywords.length}
className="bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-green-500/25 transition-all"
>
<MapPin className="w-4 h-4 mr-2" />
Google Maps durchsuchen
</Button>
) : stage === "running" ? (
<ProgressCard
title={progress.phase || "Läuft..."}
current={progress.current}
total={progress.total || totalExpected}
subtitle={enrichEmails ? "Google Maps → Anymailfinder Bulk Enrichment" : "Google Maps Suche läuft..."}
status="running"
/>
) : (
<ProgressCard
title="Fertig"
current={enrichEmails ? emailsFound : results.length}
total={results.length}
subtitle={enrichEmails ? `${emailsFound} E-Mails gefunden` : `${results.length} Unternehmen gefunden`}
status="complete"
/>
)}
</Card>
{/* Results */}
{results.length > 0 && (
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-semibold text-white">
Ergebnisse ({results.length} Unternehmen{emailsFound > 0 ? `, ${emailsFound} Emails` : ""})
</h2>
<ExportButtons
rows={exportRows}
filename={`maps-leads-${jobId?.slice(0, 8) || "export"}`}
summary={emailsFound > 0 ? `${emailsFound} E-Mails gefunden` : `${results.length} Unternehmen`}
/>
</div>
{/* Post-enrichment CTA */}
{stage === "done" && !enrichEmails && enrichStage === "idle" && (
<div className="flex items-center justify-between gap-4 bg-green-500/5 border border-green-500/20 rounded-xl px-4 py-3">
<div>
<p className="text-sm text-white font-medium">{results.length} Unternehmen gefunden jetzt Entscheider-Emails suchen?</p>
<p className="text-xs text-gray-500 mt-0.5">Über Anymailfinder · 2 Credits pro gefundener Email</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex gap-1.5 flex-wrap">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setCategory(opt.value)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all ${
category === opt.value
? "bg-green-500/20 text-green-300 border-green-500/40"
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:border-green-500/30"
}`}
>
{opt.label.split(" / ")[0]}
{opt.recommended && <span className="ml-1 text-[9px] text-green-400"></span>}
</button>
))}
</div>
<Button
onClick={startEnrichment}
className="bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-600 hover:to-teal-700 text-white font-medium px-5 whitespace-nowrap"
>
Entscheider-Emails finden
</Button>
</div>
</div>
)}
{enrichStage === "running" && (
<ProgressCard
title="Entscheider-Emails werden gesucht..."
current={enrichProgress.current}
total={enrichProgress.total}
subtitle="Anymailfinder Bulk Enrichment"
status="running"
/>
)}
{enrichStage === "done" && (
<ProgressCard
title="Anreicherung abgeschlossen"
current={emailsFound}
total={results.length}
subtitle={`${emailsFound} Entscheider-Emails gefunden`}
status="complete"
/>
)}
<ResultsTable
rows={results}
loading={stage === "running" && results.length === 0}
extraColumns={[
{ key: "address", label: "Adresse" },
{ key: "phone", label: "Telefon" },
]}
/>
</Card>
)}
{stage === "idle" && (
<EmptyState
icon={MapPin}
title="Suchbegriff eingeben und starten"
description="Gib einen Suchbegriff und Regionen ein um lokale Unternehmen über Google Maps zu finden und deren Entscheider-Emails anzureichern."
/>
)}
</div>
);
}