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>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
// Bulk API processes ~1,000 rows per 5 minutes asynchronously.
|
||||
|
||||
import axios from "axios";
|
||||
import { extractDomainFromUrl } from "@/lib/utils/domains";
|
||||
|
||||
const BASE_URL = "https://api.anymailfinder.com/v5.1";
|
||||
|
||||
@@ -141,6 +142,7 @@ export async function getBulkSearchStatus(
|
||||
/**
|
||||
* Download bulk search results as JSON array.
|
||||
* IMPORTANT: Credits are charged on first download.
|
||||
* Handles both array-of-objects and array-of-arrays (with header row) formats.
|
||||
*/
|
||||
export async function downloadBulkResults(
|
||||
searchId: string,
|
||||
@@ -151,7 +153,21 @@ export async function downloadBulkResults(
|
||||
headers: { Authorization: apiKey },
|
||||
timeout: 60000,
|
||||
});
|
||||
return response.data as Array<Record<string, string>>;
|
||||
|
||||
const raw = response.data;
|
||||
|
||||
// Handle array-of-arrays format (first row = headers)
|
||||
if (Array.isArray(raw) && Array.isArray(raw[0])) {
|
||||
const headers = (raw[0] as string[]).map(h => h.toLowerCase().trim());
|
||||
return (raw as string[][]).slice(1).map(row => {
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((h, i) => { obj[h] = row[i] ?? ""; });
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
// Standard array-of-objects format
|
||||
return raw as Array<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,20 +212,35 @@ export async function bulkSearchDomains(
|
||||
const rows = await downloadBulkResults(searchId, apiKey);
|
||||
|
||||
// 4. Normalize to DecisionMakerResult[]
|
||||
// Log first row to help debug field names
|
||||
if (rows.length > 0) {
|
||||
console.log("[Anymailfinder] Raw response sample row keys:", Object.keys(rows[0]));
|
||||
console.log("[Anymailfinder] Sample row:", JSON.stringify(rows[0]));
|
||||
}
|
||||
|
||||
return rows.map(row => {
|
||||
const rawDomain = row["domain"] || row["Domain"] || "";
|
||||
const domain = rawDomain ? extractDomainFromUrl(rawDomain.includes(".") ? rawDomain : `https://${rawDomain}`) : "";
|
||||
|
||||
// Anymailfinder bulk JSON uses different field names than the single-search API:
|
||||
// amf_status (not email_status), valid_email_only (not valid_email),
|
||||
// person_name (not person_full_name), result_title (not person_job_title),
|
||||
// linkedin_url (not person_linkedin_url)
|
||||
const email = row["email"] || row["Email"] || null;
|
||||
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
|
||||
const validEmail = emailStatus === "valid" ? email : null;
|
||||
const rawStatus = row["amf_status"] || row["email_status"] || row["Email Status"] || "not_found";
|
||||
// amf_status uses "not found" (space) — normalize to underscore variant
|
||||
const emailStatus = rawStatus.toLowerCase().replace(/\s+/g, "_") as DecisionMakerResult["email_status"];
|
||||
const validEmail = row["valid_email_only"] || row["valid_email"] || (emailStatus === "valid" ? email : null) || null;
|
||||
|
||||
return {
|
||||
domain: row["domain"] || row["Domain"] || "",
|
||||
domain,
|
||||
decision_maker_category: primaryCategory,
|
||||
email,
|
||||
email_status: emailStatus as DecisionMakerResult["email_status"],
|
||||
email: email || validEmail,
|
||||
email_status: emailStatus,
|
||||
valid_email: validEmail,
|
||||
person_full_name: row["person_full_name"] || row["Full Name"] || null,
|
||||
person_job_title: row["person_job_title"] || row["Job Title"] || null,
|
||||
person_linkedin_url: row["person_linkedin_url"] || row["LinkedIn URL"] || null,
|
||||
person_full_name: row["person_name"] || row["person_full_name"] || row["Full Name"] || null,
|
||||
person_job_title: row["result_title"] || row["person_job_title"] || row["Job Title"] || null,
|
||||
person_linkedin_url: row["linkedin_url"] || row["person_linkedin_url"] || row["LinkedIn URL"] || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function runGoogleSerpScraper(
|
||||
{
|
||||
queries: query,
|
||||
maxPagesPerQuery: maxPages,
|
||||
countryCode: countryCode.toUpperCase(),
|
||||
countryCode: countryCode.toLowerCase(),
|
||||
languageCode: languageCode.toLowerCase(),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,8 @@ const FIELD_MASK = [
|
||||
"places.formattedAddress",
|
||||
"places.nationalPhoneNumber",
|
||||
"places.businessStatus",
|
||||
"places.editorialSummary",
|
||||
"places.primaryTypeDisplayName",
|
||||
"nextPageToken",
|
||||
].join(",");
|
||||
|
||||
@@ -29,6 +31,8 @@ export interface PlaceResult {
|
||||
domain: string | null;
|
||||
address: string;
|
||||
phone: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
interface PlacesApiResponse {
|
||||
@@ -39,6 +43,8 @@ interface PlacesApiResponse {
|
||||
formattedAddress?: string;
|
||||
nationalPhoneNumber?: string;
|
||||
businessStatus?: string;
|
||||
editorialSummary?: { text: string };
|
||||
primaryTypeDisplayName?: { text: string };
|
||||
}>;
|
||||
nextPageToken?: string;
|
||||
}
|
||||
@@ -85,6 +91,8 @@ export async function searchPlaces(
|
||||
domain,
|
||||
address: place.formattedAddress || "",
|
||||
phone: place.nationalPhoneNumber || null,
|
||||
description: place.editorialSummary?.text || null,
|
||||
category: place.primaryTypeDisplayName?.text || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface VaultLead {
|
||||
linkedinUrl?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
emailConfidence?: number | null;
|
||||
serpTitle?: string | null;
|
||||
serpSnippet?: string | null;
|
||||
serpRank?: number | null;
|
||||
@@ -17,6 +16,7 @@ export interface VaultLead {
|
||||
country?: string | null;
|
||||
headcount?: string | null;
|
||||
industry?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,6 @@ export async function sinkLeadToVault(
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
@@ -76,7 +75,6 @@ export async function sinkLeadToVault(
|
||||
linkedinUrl: lead.linkedinUrl || null,
|
||||
phone: lead.phone || null,
|
||||
address: lead.address || null,
|
||||
emailConfidence: lead.emailConfidence ?? null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpSnippet: lead.serpSnippet || null,
|
||||
serpRank: lead.serpRank ?? null,
|
||||
@@ -84,6 +82,7 @@ export async function sinkLeadToVault(
|
||||
country: lead.country || null,
|
||||
headcount: lead.headcount || null,
|
||||
industry: lead.industry || null,
|
||||
description: lead.description || null,
|
||||
sourceTab,
|
||||
sourceTerm: sourceTerm || null,
|
||||
sourceJobId: sourceJobId || null,
|
||||
@@ -119,8 +118,7 @@ export async function sinkLeadsToVault(
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
@@ -142,12 +140,14 @@ export async function sinkLeadsToVault(
|
||||
linkedinUrl: lead.linkedinUrl || null,
|
||||
phone: lead.phone || null,
|
||||
address: lead.address || null,
|
||||
emailConfidence: lead.emailConfidence ?? null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpSnippet: lead.serpSnippet || null,
|
||||
serpRank: lead.serpRank ?? null,
|
||||
serpUrl: lead.serpUrl || null,
|
||||
country: lead.country || null,
|
||||
headcount: lead.headcount || null,
|
||||
industry: lead.industry || null,
|
||||
description: lead.description || null,
|
||||
sourceTab,
|
||||
sourceTerm: sourceTerm || null,
|
||||
sourceJobId: sourceJobId || null,
|
||||
|
||||
@@ -28,10 +28,6 @@ export interface ExportRow {
|
||||
contact_name?: string;
|
||||
contact_title?: string;
|
||||
email?: string;
|
||||
confidence_score?: number | string;
|
||||
source_tab?: string;
|
||||
job_id?: string;
|
||||
found_at?: string;
|
||||
}
|
||||
|
||||
export function exportToCSV(rows: ExportRow[], filename: string): void {
|
||||
|
||||
Reference in New Issue
Block a user