diff --git a/app/api/credentials/route.ts b/app/api/credentials/route.ts index 9610bbc..d95eddb 100644 --- a/app/api/credentials/route.ts +++ b/app/api/credentials/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { encrypt, decrypt } from "@/lib/utils/encryption"; -const SERVICES = ["anymailfinder", "apify", "vayne", "airscale"] as const; +const SERVICES = ["anymailfinder", "apify", "vayne", "airscale", "googlemaps"] as const; export async function GET() { try { @@ -14,7 +14,7 @@ export async function GET() { return NextResponse.json(result); } catch (err) { console.error("GET /api/credentials error:", err); - return NextResponse.json({ anymailfinder: false, apify: false, vayne: false, airscale: false }); + return NextResponse.json({ anymailfinder: false, apify: false, vayne: false, airscale: false, googlemaps: false }); } } diff --git a/app/api/credentials/test/route.ts b/app/api/credentials/test/route.ts index eda01d3..b8b4678 100644 --- a/app/api/credentials/test/route.ts +++ b/app/api/credentials/test/route.ts @@ -38,6 +38,21 @@ export async function GET(req: NextRequest) { }); return NextResponse.json({ ok: res.status === 200 }); } + case "googlemaps": { + const res = await axios.post( + "https://places.googleapis.com/v1/places:searchText", + { textQuery: "restaurant", maxResultCount: 1 }, + { + headers: { + "X-Goog-Api-Key": key, + "X-Goog-FieldMask": "places.id", + "Content-Type": "application/json", + }, + timeout: 10000, + } + ); + return NextResponse.json({ ok: res.status === 200 }); + } default: return NextResponse.json({ ok: false, error: "Unknown service" }); } diff --git a/app/api/jobs/maps-enrich/route.ts b/app/api/jobs/maps-enrich/route.ts new file mode 100644 index 0000000..981506b --- /dev/null +++ b/app/api/jobs/maps-enrich/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/utils/encryption"; +import { searchPlacesMultiQuery } from "@/lib/services/googlemaps"; +import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { + queries: string[]; + maxResultsPerQuery: number; + languageCode: string; + categories: DecisionMakerCategory[]; + enrichEmails: boolean; + }; + + const { queries, maxResultsPerQuery, languageCode, categories, enrichEmails } = body; + + if (!queries?.length) { + return NextResponse.json({ error: "No search queries provided" }, { status: 400 }); + } + + const mapsCredential = await prisma.apiCredential.findUnique({ where: { service: "googlemaps" } }); + if (!mapsCredential?.value) { + return NextResponse.json({ error: "Google Maps API key not configured" }, { status: 400 }); + } + const mapsApiKey = decrypt(mapsCredential.value); + + if (enrichEmails) { + const anymailCred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } }); + if (!anymailCred?.value) { + return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 }); + } + } + + const job = await prisma.job.create({ + data: { + type: "maps", + status: "running", + config: JSON.stringify({ queries, maxResultsPerQuery, languageCode, categories, enrichEmails }), + totalLeads: 0, + }, + }); + + runMapsEnrich(job.id, body, mapsApiKey).catch(console.error); + + return NextResponse.json({ jobId: job.id }); + } catch (err) { + console.error("POST /api/jobs/maps-enrich error:", err); + return NextResponse.json({ error: "Failed to start job" }, { status: 500 }); + } +} + +async function runMapsEnrich( + jobId: string, + params: { + queries: string[]; + maxResultsPerQuery: number; + languageCode: string; + categories: DecisionMakerCategory[]; + enrichEmails: boolean; + }, + mapsApiKey: string +) { + try { + // 1. Search Google Maps + const places = await searchPlacesMultiQuery( + params.queries, + mapsApiKey, + params.maxResultsPerQuery, + params.languageCode, + async (_done, _total) => { + await prisma.job.update({ where: { id: jobId }, data: { totalLeads: _done } }); + } + ); + + await prisma.job.update({ + where: { id: jobId }, + data: { totalLeads: places.length }, + }); + + // 2. Store raw Google Maps results immediately + for (const place of places) { + await prisma.leadResult.create({ + data: { + jobId, + companyName: place.name || null, + domain: place.domain || null, + source: JSON.stringify({ + address: place.address, + phone: place.phone, + website: place.website, + placeId: place.placeId, + }), + }, + }); + } + + // 3. Optionally enrich with Anymailfinder + if (params.enrichEmails && places.length > 0) { + const anymailCred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } }); + if (!anymailCred?.value) throw new Error("Anymailfinder key missing"); + + const anymailKey = decrypt(anymailCred.value); + const domains = places.filter(p => p.domain).map(p => p.domain!); + + // Map domain → placeId for updating results + const domainToResultId = new Map(); + const existingResults = await prisma.leadResult.findMany({ + where: { jobId }, + select: { id: true, domain: true }, + }); + for (const r of existingResults) { + if (r.domain) domainToResultId.set(r.domain, r.id); + } + + let emailsFound = 0; + const enrichResults = await bulkSearchDomains( + domains, + params.categories, + anymailKey, + async (_completed, total) => { + await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } }); + } + ); + + for (const result of enrichResults) { + const hasEmail = !!result.valid_email; + if (hasEmail) emailsFound++; + + const resultId = domainToResultId.get(result.domain || ""); + if (!resultId) continue; + + await prisma.leadResult.update({ + where: { id: resultId }, + data: { + contactName: result.person_full_name || null, + contactTitle: result.person_job_title || null, + email: result.email || null, + confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0, + linkedinUrl: result.person_linkedin_url || null, + }, + }); + + await prisma.job.update({ where: { id: jobId }, data: { emailsFound } }); + } + + await prisma.job.update({ + where: { id: jobId }, + data: { status: "complete", emailsFound, totalLeads: places.length }, + }); + } else { + await prisma.job.update({ + where: { id: jobId }, + data: { status: "complete", totalLeads: places.length }, + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await prisma.job.update({ + where: { id: jobId }, + data: { status: "failed", error: message }, + }); + } +} diff --git a/app/maps/page.tsx b/app/maps/page.tsx new file mode 100644 index 0000000..f456878 --- /dev/null +++ b/app/maps/page.tsx @@ -0,0 +1,445 @@ +"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 }[] = [ + { value: "ceo", label: "CEO / Owner / Founder" }, + { value: "engineering", label: "Engineering" }, + { value: "marketing", label: "Marketing" }, + { value: "sales", label: "Sales" }, + { value: "operations", label: "Operations" }, + { value: "finance", label: "Finance" }, + { value: "hr", label: "HR" }, + { value: "it", label: "IT" }, + { value: "buyer", label: "Procurement" }, + { value: "logistics", label: "Logistics" }, +]; + +const RESULTS_OPTIONS = [ + { value: "20", label: "20 per query" }, + { value: "40", label: "40 per query" }, + { value: "60", label: "60 per query (max)" }, +]; + +// Preset queries for common German solar use cases +const PRESET_QUERIES = [ + "Solaranlage Installateur Deutschland", + "Photovoltaik Montage", + "Solar Handwerker", + "Solarstrom Installation", +]; + +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 [keyword, setKeyword] = useState("Solaranlage Installateur"); + const [regions, setRegions] = useState(["Bayern", "Baden-Württemberg"]); + const [regionInput, setRegionInput] = useState(""); + const [maxResults, setMaxResults] = useState("60"); + const [enrichEmails, setEnrichEmails] = useState(true); + const [categories, setCategories] = useState(["ceo"]); + const [stage, setStage] = useState("idle"); + const [jobId, setJobId] = useState(null); + const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" }); + const [results, setResults] = useState([]); + const { addJob, updateJob, removeJob } = useAppStore(); + + // Build the final queries array: one per region + const queries = regions.length > 0 + ? regions.map(r => `${keyword} ${r}`) + : [keyword]; + + 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 (!keyword.trim()) return toast.error("Enter a search keyword"); + if (!categories.length && enrichEmails) return toast.error("Select at least one email category"); + + 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, + 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 ( +
+ {/* Header */} +
+
+
+
+ + Tab 4 + + Google Maps +
+

Google Maps → Email

+

+ Finde lokale Unternehmen über Google Maps und bereichere sie mit Entscheider-Emails. +

+
+
+ + {/* Info banner */} +
+ +

+ Nutzt die Google Maps Places API (New). Max. 60 Ergebnisse pro Suchanfrage. + Füge mehrere Regionen hinzu um mehr Ergebnisse zu erhalten. + + $200 Free Credit/Monat ≈ ~6.000 kostenlose Searches. + +

+
+ + {/* Step 1: Search config */} + +

+ 1 + Suchanfrage konfigurieren +

+ + {/* Keyword */} +
+ + setKeyword(e.target.value)} + className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-green-500" + /> + {/* Presets */} +
+ {PRESET_QUERIES.map(q => ( + + ))} +
+
+ + {/* Regions */} +
+ + + {/* Region chips */} +
+ {regions.map(r => ( + + {r} + + + ))} + setRegionInput(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addRegion(regionInput); } + }} + onBlur={() => regionInput && addRegion(regionInput)} + /> +
+ + {/* Region presets */} +
+ {GERMAN_REGIONS.filter(r => !regions.includes(r)).map(r => ( + + ))} +
+
+ + {/* Max results + language */} +
+
+ + +
+
+

+ = bis zu{" "} + {totalExpected.toLocaleString()}{" "} + Ergebnisse total ({queries.length} {queries.length === 1 ? "Query" : "Queries"}) +

+
+
+ + {/* Preview queries */} + {queries.length > 0 && ( +
+

Suchanfragen die ausgeführt werden:

+
+ {queries.slice(0, 5).map((q, i) => ( +
+ {i+1} + "{q}" +
+ ))} + {queries.length > 5 && ( +

+{queries.length - 5} weitere...

+ )} +
+
+ )} +
+ + {/* Step 2: Email enrichment */} + +

+ 2 + Email Enrichment +

+ +
+ setEnrichEmails(!!v)} + className="border-[#2e2e3e] mt-0.5" + /> +
+ +

+ Entscheider-Emails für alle gefundenen Domains suchen (2 Credits/gültige Email) +

+
+
+ + {enrichEmails && ( +
+ {CATEGORY_OPTIONS.map(opt => ( + + ))} +
+ )} +
+ + {/* Step 3: Run */} + +

+ 3 + Starten +

+ + {stage === "idle" || stage === "failed" ? ( + + ) : stage === "running" ? ( + + ) : ( + + )} +
+ + {/* Results */} + {results.length > 0 && ( + +
+

+ Ergebnisse ({results.length} Unternehmen{enrichEmails && emailsFound > 0 ? `, ${emailsFound} Emails` : ""}) +

+ +
+ +
+ )} + + {stage === "idle" && ( + + )} +
+ ); +} diff --git a/app/results/page.tsx b/app/results/page.tsx index a7810e0..3d976ac 100644 --- a/app/results/page.tsx +++ b/app/results/page.tsx @@ -7,7 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/shared/EmptyState"; import { StatusBadge } from "@/components/shared/ProgressCard"; import { toast } from "sonner"; -import { BarChart3, Building2, Linkedin, Search, Download, Trash2, RefreshCw } from "lucide-react"; +import { BarChart3, Building2, Linkedin, Search, MapPin, Download, Trash2, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; interface Job { @@ -32,6 +32,7 @@ const TYPE_CONFIG: Record({ anymailfinder: false, apify: false, vayne: false }); + const [creds, setCreds] = useState({ anymailfinder: false, apify: false, vayne: false, googlemaps: false }); useEffect(() => { fetch("/api/credentials") @@ -80,6 +82,7 @@ export function Sidebar() { { key: "anymailfinder", label: "Anymailfinder" }, { key: "apify", label: "Apify" }, { key: "vayne", label: "Vayne" }, + { key: "googlemaps", label: "Google Maps" }, ].map(({ key, label }) => (
diff --git a/components/layout/TopBar.tsx b/components/layout/TopBar.tsx index 193da47..8b8a3c0 100644 --- a/components/layout/TopBar.tsx +++ b/components/layout/TopBar.tsx @@ -8,6 +8,7 @@ const BREADCRUMBS: Record = { "/airscale": "AirScale → Email", "/linkedin": "LinkedIn → Email", "/serp": "SERP → Email", + "/maps": "Google Maps → Email", "/results": "Results & History", "/settings": "Settings", }; diff --git a/lib/services/googlemaps.ts b/lib/services/googlemaps.ts new file mode 100644 index 0000000..873dcf8 --- /dev/null +++ b/lib/services/googlemaps.ts @@ -0,0 +1,143 @@ +// Google Maps Places API (New) +// Docs: https://developers.google.com/maps/documentation/places/web-service/text-search +// Auth: X-Goog-Api-Key header +// Pricing: $32/1,000 requests — $200 free credit/month ≈ 6,250 free searches +// +// Text Search returns up to 20 results per page, max 3 pages (60 results) per query. +// Use multiple queries (different cities) to exceed 60 results. + +import axios from "axios"; +import { extractDomainFromUrl } from "@/lib/utils/domains"; + +const BASE_URL = "https://places.googleapis.com/v1/places:searchText"; + +// Fields we need — only request what we use to minimize billing +const FIELD_MASK = [ + "places.id", + "places.displayName", + "places.websiteUri", + "places.formattedAddress", + "places.nationalPhoneNumber", + "places.businessStatus", + "nextPageToken", +].join(","); + +export interface PlaceResult { + placeId: string; + name: string; + website: string | null; + domain: string | null; + address: string; + phone: string | null; +} + +interface PlacesApiResponse { + places?: Array<{ + id: string; + displayName?: { text: string }; + websiteUri?: string; + formattedAddress?: string; + nationalPhoneNumber?: string; + businessStatus?: string; + }>; + nextPageToken?: string; +} + +export async function searchPlaces( + textQuery: string, + apiKey: string, + maxResults = 60, + languageCode = "de" +): Promise { + const results: PlaceResult[] = []; + let pageToken: string | undefined; + + do { + const body: Record = { + textQuery, + pageSize: 20, + languageCode, + }; + if (pageToken) body.pageToken = pageToken; + + const response = await axios.post(BASE_URL, body, { + headers: { + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": FIELD_MASK, + "Content-Type": "application/json", + }, + timeout: 15000, + }); + + const data = response.data; + const places = data.places || []; + + for (const place of places) { + if (place.businessStatus && place.businessStatus !== "OPERATIONAL") continue; + + const website = place.websiteUri || null; + const domain = website ? extractDomainFromUrl(website) : null; + + results.push({ + placeId: place.id, + name: place.displayName?.text || "", + website, + domain, + address: place.formattedAddress || "", + phone: place.nationalPhoneNumber || null, + }); + } + + pageToken = data.nextPageToken; + + // Respect Google's recommendation: wait briefly between paginated requests + if (pageToken && results.length < maxResults) { + await sleep(500); + } + } while (pageToken && results.length < maxResults); + + return results.slice(0, maxResults); +} + +/** + * Search across multiple queries (e.g., different cities) and deduplicate by domain. + */ +export async function searchPlacesMultiQuery( + queries: string[], + apiKey: string, + maxResultsPerQuery = 60, + languageCode = "de", + onProgress?: (done: number, total: number) => void +): Promise { + const seenDomains = new Set(); + const seenPlaceIds = new Set(); + const allResults: PlaceResult[] = []; + + for (let i = 0; i < queries.length; i++) { + try { + const results = await searchPlaces(queries[i], apiKey, maxResultsPerQuery, languageCode); + + for (const r of results) { + if (seenPlaceIds.has(r.placeId)) continue; + if (r.domain && seenDomains.has(r.domain)) continue; + + seenPlaceIds.add(r.placeId); + if (r.domain) seenDomains.add(r.domain); + allResults.push(r); + } + + onProgress?.(i + 1, queries.length); + + // Avoid hammering the API between queries + if (i < queries.length - 1) await sleep(300); + } catch (err) { + console.error(`Google Maps search error for query "${queries[i]}":`, err); + } + } + + return allResults; +} + +function sleep(ms: number) { + return new Promise(r => setTimeout(r, ms)); +}