Files
lead-scraper/app/maps/page.tsx
Timo Uttenweiler 6711633a5d feat: Mehrere Suchbegriffe im Maps-Tab (Chip-System)
- Keywords als Chip-Input statt einzelnem Textfeld
- Queries = jedes Keyword × jede Region (Kreuzprodukt)
- Vorschläge für gängige deutsche B2B-Branchen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 14:12:47 +01:00

462 lines
19 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 { 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[];
};
if (data.totalLeads > 0 && data.emailsFound === 0 && enrichEmails) {
phase = "Enriching with Anymailfinder...";
}
if (data.emailsFound > 0) {
phase = `Found ${data.emailsFound} emails so far...`;
}
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
? `Done! ${data.totalLeads} companies found, ${data.emailsFound} emails enriched`
: `Done! ${data.totalLeads} companies found`;
toast.success(msg);
} else {
setStage("failed");
toast.error("Job failed. Check your Google Maps API key in Settings.");
}
}
} catch {
clearInterval(interval);
setStage("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,
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
source_tab: "maps",
job_id: jobId || "",
found_at: new Date().toISOString(),
}));
const emailsFound = results.filter(r => r.email).length;
const hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
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 ? `Hit Rate: ${hitRate}%` : `${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{enrichEmails && emailsFound > 0 ? `, ${emailsFound} Emails` : ""})
</h2>
<ExportButtons
rows={exportRows}
filename={`maps-leads-${jobId?.slice(0, 8) || "export"}`}
summary={enrichEmails ? `${emailsFound} Emails · ${hitRate}% Hit Rate` : `${results.length} Unternehmen`}
/>
</div>
<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>
);
}