Initial commit: LeadFlow lead generation platform

Full-stack Next.js 16 app with three scraping pipelines:
- AirScale CSV → Anymailfinder Bulk Decision Maker search
- LinkedIn Sales Navigator → Vayne → Anymailfinder email enrichment
- Apify Google SERP → domain extraction → Anymailfinder bulk enrichment

Includes Docker multi-stage build + docker-compose for Coolify deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-03-17 11:21:11 +01:00
parent 5b84001c1e
commit facf8c9f69
59 changed files with 5800 additions and 233 deletions

364
app/airscale/page.tsx Normal file
View File

@@ -0,0 +1,364 @@
"use client";
import { useState, useCallback } 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 { FileDropZone } from "@/components/shared/FileDropZone";
import { RoleChipsInput } from "@/components/shared/RoleChipsInput";
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 { parseCSV, detectDomainColumn, type ExportRow } from "@/lib/utils/csv";
import { cleanDomain } from "@/lib/utils/domains";
import { toast } from "sonner";
import { Building2, ChevronRight, AlertCircle } from "lucide-react";
import { useAppStore } from "@/lib/store";
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const DEFAULT_ROLES: DecisionMakerCategory[] = ["ceo"];
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
{ value: "ceo", label: "CEO / Owner / Founder" },
{ value: "engineering", label: "Engineering" },
{ value: "finance", label: "Finance" },
{ value: "hr", label: "HR" },
{ value: "it", label: "IT" },
{ value: "logistics", label: "Logistics" },
{ value: "marketing", label: "Marketing" },
{ value: "operations", label: "Operations" },
{ value: "buyer", label: "Procurement" },
{ value: "sales", label: "Sales" },
];
interface CompanyRow {
name: string;
domain: string;
}
export default function AirScalePage() {
const [csvData, setCsvData] = useState<Record<string, string>[]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [domainCol, setDomainCol] = useState<string>("");
const [nameCol, setNameCol] = useState<string>("");
const [categories, setCategories] = useState<DecisionMakerCategory[]>(DEFAULT_ROLES);
const [jobId, setJobId] = useState<string | null>(null);
const [jobStatus, setJobStatus] = useState<string>("idle");
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [results, setResults] = useState<ResultRow[]>([]);
const [running, setRunning] = useState(false);
const { addJob, updateJob, removeJob } = useAppStore();
const onFile = useCallback((content: string) => {
const { data, headers: h } = parseCSV(content);
setCsvData(data);
setHeaders(h);
const detected = detectDomainColumn(h);
if (detected) setDomainCol(detected);
const nameGuess = h.find(x => /company|name|firma/i.test(x));
if (nameGuess) setNameCol(nameGuess);
}, []);
const companies: CompanyRow[] = csvData
.map(row => ({
name: nameCol ? (row[nameCol] || "") : "",
domain: cleanDomain(row[domainCol] || ""),
}))
.filter(c => c.domain);
const withDomain = companies.length;
const withoutDomain = csvData.length - withDomain;
const startEnrichment = async () => {
if (!companies.length) return toast.error("No companies with domains found");
if (!categories.length) return toast.error("Select at least one decision maker category");
setRunning(true);
setResults([]);
setJobStatus("running");
try {
const res = await fetch("/api/jobs/airscale-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ companies, categories }),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed to start job");
setJobId(data.jobId);
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
toast.success("Enrichment started!");
pollJob(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start");
setRunning(false);
setJobStatus("failed");
}
};
const pollJob = (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;
};
setProgress({ current: data.emailsFound, total: data.totalLeads });
setResults(data.results || []);
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
setJobStatus(data.status);
setRunning(false);
removeJob(id);
if (data.status === "complete") {
toast.success(`Done! Found ${data.emailsFound} emails from ${data.totalLeads} companies`);
} else {
toast.error(`Job failed: ${data.error || "Unknown error"}`);
}
}
} catch {
clearInterval(interval);
setRunning(false);
}
}, 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: "airscale",
job_id: jobId || "",
found_at: new Date().toISOString(),
}));
const hitRate = results.length > 0
? Math.round((results.filter(r => r.email).length / results.length) * 100)
: 0;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-blue-600/10 to-purple-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent" />
<div className="relative">
<div className="flex items-center gap-2 text-sm text-blue-400 mb-2">
<Building2 className="w-4 h-4" />
<span>Tab 1</span>
<ChevronRight className="w-3 h-3" />
<span>AirScale Companies</span>
</div>
<h1 className="text-2xl font-bold text-white">AirScale Email Enrichment</h1>
<p className="text-gray-400 mt-1 text-sm">
Upload an AirScale CSV export and find decision maker emails via Anymailfinder.
</p>
</div>
</div>
{/* Step 1: Upload */}
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
Upload AirScale CSV
</h2>
<FileDropZone onFile={onFile} label="Drop your AirScale CSV export here" />
{csvData.length > 0 && (
<div className="space-y-4">
{/* Stats */}
<div className="flex gap-4">
{[
{ label: "Total rows", value: csvData.length, color: "text-white" },
{ label: "With domains", value: withDomain, color: "text-green-400" },
{ label: "Missing domains", value: withoutDomain, color: "text-yellow-400" },
].map(stat => (
<div key={stat.label} className="bg-[#0d0d18] rounded-lg px-4 py-2.5 border border-[#1e1e2e]">
<p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
))}
</div>
{/* Column mapper */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Domain Column</Label>
<Select value={domainCol} onValueChange={v => setDomainCol(v ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select domain column..." />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{headers.map(h => (
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Company Name Column (optional)</Label>
<Select value={nameCol} onValueChange={v => setNameCol(v ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select name column..." />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
<SelectItem value="" className="text-gray-400">None</SelectItem>
{headers.map(h => (
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Preview */}
<div className="rounded-lg border border-[#1e1e2e] overflow-hidden">
<div className="bg-[#0d0d18] px-4 py-2 text-xs text-gray-500 border-b border-[#1e1e2e]">
Preview (first 5 rows)
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[#1e1e2e]">
{headers.slice(0, 6).map(h => (
<th key={h} className="px-3 py-2 text-left text-gray-400">{h}</th>
))}
</tr>
</thead>
<tbody>
{csvData.slice(0, 5).map((row, i) => (
<tr key={i} className="border-b border-[#1e1e2e]/50">
{headers.slice(0, 6).map(h => (
<td key={h} className="px-3 py-2 text-gray-300 truncate max-w-[150px]">
{row[h] || "—"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</Card>
{/* Step 2: Configure */}
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories
</h2>
<div className="space-y-3">
<Label className="text-gray-300 text-sm">Select categories to search (in order of priority)</Label>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => {
setCategories(prev =>
prev.includes(opt.value)
? prev.filter(c => c !== opt.value)
: [...prev, opt.value]
);
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
categories.includes(opt.value)
? "bg-blue-500/20 text-blue-300 border-blue-500/40"
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-blue-500/30"
}`}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs text-gray-500">
Categories are searched in priority order. First category with a valid result wins.
</p>
</div>
</Card>
{/* Step 3: Run */}
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
Run Enrichment
</h2>
{!running && jobStatus === "idle" && (
csvData.length === 0 ? (
<EmptyState
icon={Building2}
title="Upload a CSV to get started"
description="Upload your AirScale export above, then configure and run enrichment."
/>
) : (
<Button
onClick={startEnrichment}
disabled={!withDomain || !domainCol || !categories.length}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
>
Start Enrichment ({withDomain} companies)
</Button>
)
)}
{(running || jobStatus === "running") && (
<ProgressCard
title="Enriching companies..."
current={progress.current}
total={progress.total || withDomain}
subtitle="Finding decision maker emails via Anymailfinder"
status="running"
/>
)}
{jobStatus === "complete" && (
<ProgressCard
title="Enrichment complete"
current={progress.current}
total={progress.total}
subtitle={`Hit rate: ${hitRate}%`}
status="complete"
/>
)}
{jobStatus === "failed" && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
Enrichment failed. Check your API key in Settings.
</div>
)}
</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 flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">4</span>
Results
</h2>
<ExportButtons
rows={exportRows}
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${results.filter(r => r.email).length} emails found • ${hitRate}% hit rate`}
/>
</div>
<ResultsTable rows={results} loading={running && results.length === 0} />
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
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;
export async function GET() {
try {
const creds = await prisma.apiCredential.findMany();
const result: Record<string, boolean> = {};
for (const svc of SERVICES) {
result[svc] = creds.some(c => c.service === svc && c.value);
}
return NextResponse.json(result);
} catch (err) {
console.error("GET /api/credentials error:", err);
return NextResponse.json({ anymailfinder: false, apify: false, vayne: false, airscale: false });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as Record<string, string>;
for (const [service, value] of Object.entries(body)) {
if (!SERVICES.includes(service as typeof SERVICES[number])) continue;
if (value === null || value === undefined) continue;
await prisma.apiCredential.upsert({
where: { service },
create: { service, value: value ? encrypt(value) : "" },
update: { value: value ? encrypt(value) : "" },
});
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("POST /api/credentials error:", err);
return NextResponse.json({ error: "Failed to save credentials" }, { status: 500 });
}
}
// GET a specific decrypted value (for internal API use only)
export async function PUT(req: NextRequest) {
try {
const { service } = await req.json() as { service: string };
const cred = await prisma.apiCredential.findUnique({ where: { service } });
if (!cred) return NextResponse.json({ value: null });
return NextResponse.json({ value: decrypt(cred.value) });
} catch (err) {
console.error("PUT /api/credentials error:", err);
return NextResponse.json({ error: "Failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import axios from "axios";
export async function GET(req: NextRequest) {
const service = req.nextUrl.searchParams.get("service");
if (!service) return NextResponse.json({ ok: false, error: "Missing service" }, { status: 400 });
const cred = await prisma.apiCredential.findUnique({ where: { service } });
if (!cred?.value) return NextResponse.json({ ok: false, error: "Not configured" });
const key = decrypt(cred.value);
if (!key) return NextResponse.json({ ok: false, error: "Empty key" });
try {
switch (service) {
case "anymailfinder": {
// Test with a known domain — no credits charged if email not found
const res = await axios.post(
"https://api.anymailfinder.com/v5.1/find-email/decision-maker",
{ domain: "microsoft.com", decision_maker_category: ["ceo"] },
{ headers: { Authorization: key }, timeout: 15000 }
);
return NextResponse.json({ ok: res.status === 200 });
}
case "apify": {
const res = await axios.get("https://api.apify.com/v2/users/me", {
params: { token: key },
timeout: 10000,
});
return NextResponse.json({ ok: res.status === 200 });
}
case "vayne": {
const res = await axios.get("https://www.vayne.io/api/credits", {
headers: { Authorization: `Bearer ${key}` },
timeout: 10000,
});
return NextResponse.json({ ok: res.status === 200 });
}
default:
return NextResponse.json({ ok: false, error: "Unknown service" });
}
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
// 402 Payment Required = valid key but no credits → still connected
if (status === 402) return NextResponse.json({ ok: true });
return NextResponse.json({ ok: false });
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import Papa from "papaparse";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
try {
const { jobId } = await params;
const job = await prisma.job.findUnique({
where: { id: jobId },
include: { results: { orderBy: { createdAt: "asc" } } },
});
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
const rows = job.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 !== null ? Math.round((r.confidence || 0) * 100) + "%" : "",
source_tab: job.type,
job_id: job.id,
found_at: r.createdAt.toISOString(),
}));
const csv = Papa.unparse(rows);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.csv"`,
},
});
} catch (err) {
console.error("GET /api/export/[jobId] error:", err);
return NextResponse.json({ error: "Failed to export" }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.job.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("DELETE /api/jobs/[id] error:", err);
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = await prisma.job.findUnique({
where: { id },
include: {
results: {
orderBy: { createdAt: "desc" },
take: 200,
},
},
});
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
return NextResponse.json({
id: job.id,
type: job.type,
status: job.status,
config: JSON.parse(job.config),
totalLeads: job.totalLeads,
emailsFound: job.emailsFound,
error: job.error,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
results: job.results.map(r => ({
id: r.id,
companyName: r.companyName,
domain: r.domain,
contactName: r.contactName,
contactTitle: r.contactTitle,
email: r.email,
confidence: r.confidence,
linkedinUrl: r.linkedinUrl,
createdAt: r.createdAt,
})),
});
} catch (err) {
console.error("GET /api/jobs/[id]/status error:", err);
return NextResponse.json({ error: "Failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import { cleanDomain } from "@/lib/utils/domains";
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
companies: Array<{ name: string; domain: string }>;
categories: DecisionMakerCategory[];
};
const { companies, categories } = body;
if (!companies?.length) {
return NextResponse.json({ error: "No companies provided" }, { status: 400 });
}
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
if (!cred?.value) {
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
}
const apiKey = decrypt(cred.value);
// Build domain → company map
const domainMap = new Map<string, string>();
for (const c of companies) {
const d = cleanDomain(c.domain);
if (d) domainMap.set(d, c.name);
}
const domains = Array.from(domainMap.keys());
const job = await prisma.job.create({
data: {
type: "airscale",
status: "running",
config: JSON.stringify({ categories, totalDomains: domains.length }),
totalLeads: domains.length,
},
});
// Run enrichment in background
runEnrichment(job.id, domains, domainMap, categories, apiKey).catch(console.error);
return NextResponse.json({ jobId: job.id });
} catch (err) {
console.error("POST /api/jobs/airscale-enrich error:", err);
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
}
}
async function runEnrichment(
jobId: string,
domains: string[],
domainMap: Map<string, string>,
categories: DecisionMakerCategory[],
apiKey: string
) {
try {
// Use bulk API: submit all domains, poll for completion, then store results.
const results = await bulkSearchDomains(
domains,
categories,
apiKey,
async (processed, total) => {
// Update progress while bulk job is running
await prisma.job.update({
where: { id: jobId },
data: { totalLeads: total },
});
}
);
// Store all results
let emailsFound = 0;
for (const result of results) {
const hasEmail = !!result.valid_email;
if (hasEmail) emailsFound++;
await prisma.leadResult.create({
data: {
jobId,
companyName: domainMap.get(result.domain || "") || null,
domain: result.domain || null,
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,
source: JSON.stringify({ email_status: result.email_status, category: result.decision_maker_category }),
},
});
}
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: results.length },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: jobId },
data: { status: "failed", error: message },
});
}
}

View File

@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import {
submitBulkPersonSearch,
getBulkSearchStatus,
downloadBulkResults,
searchDecisionMakerByDomain,
type DecisionMakerCategory,
} from "@/lib/services/anymailfinder";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
jobId: string;
resultIds: string[];
categories: DecisionMakerCategory[];
};
const { jobId, resultIds, categories } = body;
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
if (!cred?.value) {
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
}
const apiKey = decrypt(cred.value);
const results = await prisma.leadResult.findMany({
where: { id: { in: resultIds }, jobId, domain: { not: null } },
});
const enrichJob = await prisma.job.create({
data: {
type: "linkedin-enrich",
status: "running",
config: JSON.stringify({ parentJobId: jobId, categories }),
totalLeads: results.length,
},
});
runLinkedInEnrich(enrichJob.id, jobId, results, categories, apiKey).catch(console.error);
return NextResponse.json({ jobId: enrichJob.id });
} catch (err) {
console.error("POST /api/jobs/linkedin-enrich error:", err);
return NextResponse.json({ error: "Failed to start enrichment" }, { status: 500 });
}
}
async function runLinkedInEnrich(
enrichJobId: string,
parentJobId: string,
results: Array<{
id: string; domain: string | null; contactName: string | null;
companyName: string | null; contactTitle: string | null; linkedinUrl: string | null;
}>,
categories: DecisionMakerCategory[],
apiKey: string
) {
let emailsFound = 0;
try {
// Separate results into those with names (person search) and those without (decision maker search)
const withNames: typeof results = [];
const withoutNames: typeof results = [];
for (const r of results) {
if (r.contactName && r.domain) {
withNames.push(r);
} else if (r.domain) {
withoutNames.push(r);
}
}
// Map to look up results by domain
const resultByDomain = new Map(results.map(r => [r.domain!, r]));
// 1. Bulk person name search for leads with names
if (withNames.length > 0) {
const leads = withNames.map(r => {
const nameParts = (r.contactName || "").trim().split(/\s+/);
return {
domain: r.domain!,
firstName: nameParts[0] || "",
lastName: nameParts.slice(1).join(" ") || "",
};
});
try {
const searchId = await submitBulkPersonSearch(leads, apiKey, `linkedin-enrich-${enrichJobId}`);
// Poll for completion
let status;
do {
await sleep(5000);
status = await getBulkSearchStatus(searchId, apiKey);
} while (status.status !== "completed" && status.status !== "failed");
if (status.status === "completed") {
const rows = await downloadBulkResults(searchId, apiKey);
for (const row of rows) {
const domain = row["domain"] || row["Domain"] || "";
const result = resultByDomain.get(domain);
if (!result) continue;
const email = row["email"] || row["Email"] || null;
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
const isValid = emailStatus === "valid";
if (isValid) emailsFound++;
await prisma.leadResult.update({
where: { id: result.id },
data: {
email: email || null,
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
},
});
}
}
} catch (err) {
console.error("Bulk person search error:", err);
// Fall through — will attempt decision-maker search below
}
}
// 2. Decision-maker search for leads without names
for (const r of withoutNames) {
if (!r.domain) continue;
try {
const found = await searchDecisionMakerByDomain(r.domain, categories, apiKey);
const isValid = !!found.valid_email;
if (isValid) emailsFound++;
await prisma.leadResult.update({
where: { id: r.id },
data: {
email: found.email || null,
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
contactName: found.person_full_name || r.contactName || null,
contactTitle: found.person_job_title || r.contactTitle || null,
},
});
await prisma.job.update({ where: { id: enrichJobId }, data: { emailsFound } });
} catch (err) {
console.error(`Decision-maker search error for domain ${r.domain}:`, err);
}
}
await prisma.job.update({
where: { id: enrichJobId },
data: { status: "complete", emailsFound },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: enrichJobId },
data: { status: "failed", error: message },
});
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}

41
app/api/jobs/route.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const jobs = await prisma.job.findMany({
orderBy: { createdAt: "desc" },
take: 100,
});
const totalLeads = jobs.reduce((s, j) => s + j.totalLeads, 0);
const totalEmails = jobs.reduce((s, j) => s + j.emailsFound, 0);
const completedJobs = jobs.filter(j => j.status === "complete" && j.totalLeads > 0);
const avgHitRate = completedJobs.length > 0
? Math.round(
completedJobs.reduce((s, j) => s + (j.emailsFound / j.totalLeads) * 100, 0) / completedJobs.length
)
: 0;
return NextResponse.json({
jobs: jobs.map(j => ({
id: j.id,
type: j.type,
status: j.status,
totalLeads: j.totalLeads,
emailsFound: j.emailsFound,
createdAt: j.createdAt,
error: j.error,
})),
stats: {
totalJobs: jobs.length,
totalLeads,
totalEmails,
avgHitRate,
},
});
} catch (err) {
console.error("GET /api/jobs error:", err);
return NextResponse.json({ jobs: [], stats: {} }, { status: 500 });
}
}

View File

@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import { isSocialOrDirectory } from "@/lib/utils/domains";
import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify";
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
query: string;
maxPages: number;
countryCode: string;
languageCode: string;
filterSocial: boolean;
categories: DecisionMakerCategory[];
selectedDomains?: string[];
};
const apifyCred = await prisma.apiCredential.findUnique({ where: { service: "apify" } });
const anymailCred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
if (!apifyCred?.value) return NextResponse.json({ error: "Apify API token not configured" }, { status: 400 });
if (!anymailCred?.value) return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
const apifyToken = decrypt(apifyCred.value);
const anymailKey = decrypt(anymailCred.value);
const job = await prisma.job.create({
data: {
type: "serp",
status: "running",
config: JSON.stringify(body),
totalLeads: 0,
},
});
runSerpEnrich(job.id, body, apifyToken, anymailKey).catch(console.error);
return NextResponse.json({ jobId: job.id });
} catch (err) {
console.error("POST /api/jobs/serp-enrich error:", err);
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
}
}
async function runSerpEnrich(
jobId: string,
params: {
query: string; maxPages: number; countryCode: string; languageCode: string;
filterSocial: boolean; categories: DecisionMakerCategory[]; selectedDomains?: string[];
},
apifyToken: string,
anymailKey: string
) {
try {
// 1. Run Apify SERP scraper
const runId = await runGoogleSerpScraper(
params.query, params.maxPages, params.countryCode, params.languageCode, apifyToken
);
// 2. Poll until complete
let runStatus = "";
let datasetId = "";
while (runStatus !== "SUCCEEDED" && runStatus !== "FAILED" && runStatus !== "ABORTED") {
await sleep(3000);
const result = await pollRunStatus(runId, apifyToken);
runStatus = result.status;
datasetId = result.defaultDatasetId;
}
if (runStatus !== "SUCCEEDED") throw new Error(`Apify run ${runStatus}`);
// 3. Fetch results
let serpResults = await fetchDatasetItems(datasetId, apifyToken);
// 4. Filter social/directories
if (params.filterSocial) {
serpResults = serpResults.filter(r => !isSocialOrDirectory(r.domain));
}
// 5. Deduplicate domains
const seenDomains = new Set<string>();
const uniqueResults = serpResults.filter(r => {
if (!r.domain || seenDomains.has(r.domain)) return false;
seenDomains.add(r.domain);
return true;
});
// 6. Apply selectedDomains filter if provided
const filteredResults = params.selectedDomains?.length
? uniqueResults.filter(r => params.selectedDomains!.includes(r.domain))
: uniqueResults;
const domains = filteredResults.map(r => r.domain);
const serpMap = new Map(filteredResults.map(r => [r.domain, r]));
await prisma.job.update({
where: { id: jobId },
data: { totalLeads: domains.length },
});
// 7. Enrich with Anymailfinder Bulk API
const enrichResults = await bulkSearchDomains(
domains,
params.categories,
anymailKey,
async (_completed, total) => {
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
}
);
// 8. Store results
let emailsFound = 0;
for (const result of enrichResults) {
const serpData = serpMap.get(result.domain || "");
const hasEmail = !!result.valid_email;
if (hasEmail) emailsFound++;
await prisma.leadResult.create({
data: {
jobId,
companyName: serpData?.title || null,
domain: result.domain || null,
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,
source: JSON.stringify({
url: serpData?.url,
description: serpData?.description,
position: serpData?.position,
email_status: result.email_status,
}),
},
});
}
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: jobId },
data: { status: "failed", error: message },
});
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}

View File

@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import { createOrder, getOrderStatus, triggerExport, downloadOrderCSV } from "@/lib/services/vayne";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { salesNavUrl: string; maxResults: number };
const { salesNavUrl, maxResults } = body;
if (!salesNavUrl?.includes("linkedin.com/sales")) {
return NextResponse.json({ error: "Invalid Sales Navigator URL" }, { status: 400 });
}
const cred = await prisma.apiCredential.findUnique({ where: { service: "vayne" } });
if (!cred?.value) {
return NextResponse.json({ error: "Vayne API token not configured" }, { status: 400 });
}
const apiToken = decrypt(cred.value);
const job = await prisma.job.create({
data: {
type: "linkedin",
status: "running",
config: JSON.stringify({ salesNavUrl, maxResults }),
totalLeads: 0,
},
});
runVayneScrape(job.id, salesNavUrl, maxResults, apiToken).catch(console.error);
return NextResponse.json({ jobId: job.id });
} catch (err) {
console.error("POST /api/jobs/vayne-scrape error:", err);
return NextResponse.json({ error: "Failed to start scrape" }, { status: 500 });
}
}
async function runVayneScrape(
jobId: string,
salesNavUrl: string,
maxResults: number,
apiToken: string
) {
try {
// 1. Create Vayne order
const order = await createOrder(salesNavUrl, maxResults, apiToken, `LeadFlow-${jobId.slice(0, 8)}`);
const orderId = order.id;
await prisma.job.update({
where: { id: jobId },
data: { config: JSON.stringify({ salesNavUrl, maxResults, vayneOrderId: orderId }) },
});
// 2. Poll until finished
let status = order.scraping_status;
let scraped = 0;
while (status !== "finished" && status !== "failed") {
await sleep(5000);
const updated = await getOrderStatus(orderId, apiToken);
status = updated.scraping_status;
scraped = updated.scraped || 0;
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: scraped } });
}
if (status === "failed") {
throw new Error("Vayne scraping failed");
}
// 3. Trigger export
let exportOrder = await triggerExport(orderId, apiToken);
// 4. Poll for export completion
let exportStatus = exportOrder.exports?.[0]?.status;
while (exportStatus !== "completed") {
await sleep(3000);
exportOrder = await getOrderStatus(orderId, apiToken);
exportStatus = exportOrder.exports?.[0]?.status;
if (exportStatus === undefined) break; // fallback
}
const fileUrl = exportOrder.exports?.[0]?.file_url;
if (!fileUrl) throw new Error("No export file URL returned by Vayne");
// 5. Download and parse CSV
const profiles = await downloadOrderCSV(fileUrl);
// 6. Store results
await prisma.leadResult.createMany({
data: profiles.map(p => ({
jobId,
companyName: p.company || null,
domain: p.companyDomain || null,
contactName: p.fullName || null,
contactTitle: p.title || null,
linkedinUrl: p.linkedinUrl || null,
source: JSON.stringify({ location: p.location }),
})),
});
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", totalLeads: profiles.length },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: jobId },
data: { status: "failed", error: message },
});
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}

View File

@@ -1,129 +1,30 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: #0a0a0f;
--card: #111118;
--border: #1e1e2e;
--primary: #3b82f6;
--secondary: #8b5cf6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--foreground: #f0f0f5;
--muted: #6b7280;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
* {
border-color: var(--border);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0a0a0f; }
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }

View File

@@ -1,33 +1,31 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { TopBar } from "@/components/layout/TopBar";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "LeadFlow — Lead Generation Platform",
description: "Unified lead generation and email enrichment platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="en" className="dark">
<body className={`${inter.variable} antialiased`}>
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
<Toaster position="bottom-right" theme="dark" />
</body>
</html>
);

419
app/linkedin/page.tsx Normal file
View File

@@ -0,0 +1,419 @@
"use client";
import { useState, useEffect } 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 { Textarea } from "@/components/ui/textarea";
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 {
Linkedin, ChevronRight, AlertTriangle, CheckCircle2, XCircle,
ChevronDown, ChevronUp, Info
} from "lucide-react";
import { useAppStore } from "@/lib/store";
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
import type { ExportRow } from "@/lib/utils/csv";
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" },
];
type Stage = "idle" | "scraping" | "scraped" | "enriching" | "done" | "failed";
export default function LinkedInPage() {
const [salesNavUrl, setSalesNavUrl] = useState("");
const [maxResults, setMaxResults] = useState(100);
const [vayneConfigured, setVayneConfigured] = useState<boolean | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
const [stage, setStage] = useState<Stage>("idle");
const [scrapeJobId, setScrapeJobId] = useState<string | null>(null);
const [enrichJobId, setEnrichJobId] = useState<string | null>(null);
const [scrapeProgress, setScrapeProgress] = useState({ current: 0, total: 0 });
const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 });
const [results, setResults] = useState<ResultRow[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [categories, setCategories] = useState<DecisionMakerCategory[]>(["ceo"]);
const { addJob, updateJob, removeJob } = useAppStore();
useEffect(() => {
fetch("/api/credentials")
.then(r => r.json())
.then((d: Record<string, boolean>) => setVayneConfigured(d.vayne))
.catch(() => setVayneConfigured(false));
}, []);
const urlValid = salesNavUrl.includes("linkedin.com/sales");
const startScrape = async () => {
if (!urlValid) return toast.error("Please paste a valid Sales Navigator URL");
if (!vayneConfigured) return toast.error("Configure your Vayne API token in Settings first");
setStage("scraping");
setScrapeProgress({ current: 0, total: maxResults });
setResults([]);
try {
const res = await fetch("/api/jobs/vayne-scrape", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ salesNavUrl, maxResults }),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
setScrapeJobId(data.jobId);
addJob({ id: data.jobId, type: "linkedin-scrape", status: "running", progress: 0, total: maxResults });
pollScrape(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start scrape");
setStage("failed");
}
};
const pollScrape = (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; results: ResultRow[];
};
setScrapeProgress({ current: data.totalLeads, total: maxResults });
if (data.results?.length) setResults(data.results);
updateJob(id, { status: data.status, progress: data.totalLeads });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
removeJob(id);
if (data.status === "complete") {
setStage("scraped");
setResults(data.results || []);
setSelectedIds(data.results?.map(r => r.id) || []);
toast.success(`Scraped ${data.totalLeads} profiles from LinkedIn`);
} else {
setStage("failed");
toast.error("Scrape failed. Check Vayne token in Settings.");
}
}
} catch {
clearInterval(interval);
setStage("failed");
}
}, 2000);
};
const startEnrich = async () => {
if (!selectedIds.length) return toast.error("Select at least one profile to enrich");
if (!categories.length) return toast.error("Select at least one category");
setStage("enriching");
setEnrichProgress({ current: 0, total: selectedIds.length });
try {
const res = await fetch("/api/jobs/linkedin-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: scrapeJobId, resultIds: selectedIds, categories }),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
setEnrichJobId(data.jobId);
addJob({ id: data.jobId, type: "linkedin-enrich", status: "running", progress: 0, total: selectedIds.length });
pollEnrich(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start enrichment");
setStage("scraped");
}
};
const pollEnrich = (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;
};
setEnrichProgress({ current: data.emailsFound, total: data.totalLeads });
updateJob(id, { status: data.status, progress: data.emailsFound });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
removeJob(id);
// Refresh results from scrape job
const jobRes = await fetch(`/api/jobs/${scrapeJobId}/status`);
const jobData = await jobRes.json() as { results: ResultRow[] };
setResults(jobData.results || []);
if (data.status === "complete") {
setStage("done");
toast.success(`Found ${data.emailsFound} emails`);
} else {
setStage("scraped");
toast.error("Enrichment failed");
}
}
} catch {
clearInterval(interval);
}
}, 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: "linkedin",
job_id: scrapeJobId || "",
found_at: new Date().toISOString(),
}));
const emailsFound = results.filter(r => r.email).length;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-blue-700/10 to-blue-500/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/5 to-transparent" />
<div className="relative">
<div className="flex items-center gap-2 text-sm text-blue-400 mb-2">
<Linkedin className="w-4 h-4" />
<span>Tab 2</span>
<ChevronRight className="w-3 h-3" />
<span>LinkedIn Sales Navigator</span>
</div>
<h1 className="text-2xl font-bold text-white">LinkedIn Email Pipeline</h1>
<p className="text-gray-400 mt-1 text-sm">
Scrape Sales Navigator profiles via Vayne, then enrich with Anymailfinder.
</p>
</div>
</div>
{/* Filter Guide */}
<Card className="bg-[#111118] border-purple-500/20 p-0 overflow-hidden">
<button
onClick={() => setGuideOpen(g => !g)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-purple-500/5 transition-colors"
>
<div className="flex items-center gap-2 text-purple-300 font-medium">
<Info className="w-4 h-4" />
Recommended Sales Navigator Filter Settings
</div>
{guideOpen ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
</button>
{guideOpen && (
<div className="px-6 pb-5 border-t border-purple-500/10 space-y-4 text-sm">
<div className="grid grid-cols-2 gap-4 pt-4">
<div>
<p className="text-gray-400 font-medium mb-2">Keywords</p>
<div className="flex flex-wrap gap-1.5">
{["Solarlösungen", "Founder", "Co-Founder", "CEO", "Geschäftsführer"].map(k => (
<span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
))}
</div>
</div>
<div>
<p className="text-gray-400 font-medium mb-2">Headcount</p>
<div className="flex flex-wrap gap-1.5">
{["110", "1150", "51200"].map(k => (
<span key={k} className="bg-blue-500/10 text-blue-300 px-2 py-0.5 rounded text-xs">{k}</span>
))}
</div>
</div>
<div>
<p className="text-gray-400 font-medium mb-2">Country</p>
<span className="bg-green-500/10 text-green-300 px-2 py-0.5 rounded text-xs">Germany (Deutschland)</span>
</div>
<div>
<p className="text-gray-400 font-medium mb-2">Target Titles</p>
<div className="flex flex-wrap gap-1.5">
{["Founder", "Co-Founder", "CEO", "CTO", "COO", "Owner", "President", "Principal", "Partner"].map(k => (
<span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
))}
</div>
</div>
</div>
</div>
)}
</Card>
{/* Step 1: Input */}
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
Sales Navigator URL
</h2>
{/* Vayne status */}
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border text-sm ${
vayneConfigured === null ? "bg-[#0d0d18] border-[#1e1e2e] text-gray-400"
: vayneConfigured ? "bg-green-500/5 border-green-500/20 text-green-400"
: "bg-red-500/5 border-red-500/20 text-red-400"
}`}>
{vayneConfigured === null ? "Checking Vayne configuration..."
: vayneConfigured ? (
<><CheckCircle2 className="w-4 h-4" /> Vayne API token configured</>
) : (
<><XCircle className="w-4 h-4" /> Vayne token not configured <a href="/settings" className="underline">go to Settings</a></>
)}
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Sales Navigator Search URL</Label>
<Textarea
placeholder="https://www.linkedin.com/sales/search/people?query=..."
value={salesNavUrl}
onChange={e => setSalesNavUrl(e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20 font-mono text-xs"
/>
{salesNavUrl && !urlValid && (
<p className="text-xs text-red-400 mt-1">Must be a linkedin.com/sales/search URL</p>
)}
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Max results to scrape</Label>
<Input
type="number"
min={1}
max={2500}
value={maxResults}
onChange={e => setMaxResults(Number(e.target.value))}
className="bg-[#0d0d18] border-[#2e2e3e] text-white w-32"
/>
</div>
{/* Warning */}
<div className="flex items-start gap-2 bg-yellow-500/5 border border-yellow-500/20 rounded-lg px-4 py-3">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-yellow-300">
Do not close this tab while scraping is in progress. The job runs in the background
but progress tracking requires this tab to remain open.
</p>
</div>
<Button
onClick={startScrape}
disabled={!urlValid || !vayneConfigured || stage === "scraping"}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all disabled:opacity-50"
>
Start LinkedIn Scrape
</Button>
</Card>
{/* Scrape progress */}
{(stage === "scraping") && (
<ProgressCard
title="Scraping LinkedIn profiles via Vayne..."
current={scrapeProgress.current}
total={scrapeProgress.total}
subtitle="Creating order → Scraping → Generating export..."
status="running"
/>
)}
{/* Results table */}
{results.length > 0 && (
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
Scraped Profiles ({results.length})
</h2>
<p className="text-xs text-gray-500">Select profiles to include in email enrichment</p>
<ResultsTable
rows={results}
selectable
onSelectionChange={setSelectedIds}
/>
</Card>
)}
{/* Step 3: Enrich */}
{(stage === "scraped" || stage === "enriching" || stage === "done") && results.length > 0 && (
<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-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
Enrich with Emails
</h2>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setCategories(prev =>
prev.includes(opt.value) ? prev.filter(c => c !== opt.value) : [...prev, opt.value]
)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
categories.includes(opt.value)
? "bg-blue-500/20 text-blue-300 border-blue-500/40"
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-blue-500/30"
}`}
>
{opt.label}
</button>
))}
</div>
{stage === "enriching" && (
<ProgressCard
title="Enriching profiles..."
current={enrichProgress.current}
total={enrichProgress.total}
subtitle="Finding emails via Anymailfinder"
status="running"
/>
)}
{stage === "scraped" && (
<Button
onClick={startEnrich}
disabled={!selectedIds.length || !categories.length}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
>
Enrich {selectedIds.length} Selected Profiles with Emails
</Button>
)}
</Card>
)}
{/* Export */}
{(stage === "done" || emailsFound > 0) && (
<Card className="bg-[#111118] border-[#1e1e2e] p-6">
<ExportButtons
rows={exportRows}
filename={`linkedin-leads-${scrapeJobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found from ${results.length} profiles`}
/>
</Card>
)}
{/* Empty state */}
{stage === "idle" && (
<EmptyState
icon={Linkedin}
title="Start by pasting a Sales Navigator URL"
description="Configure your search filters in Sales Navigator, copy the URL, and paste it above to begin scraping."
/>
)}
</div>
);
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/airscale");
}

213
app/results/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { cn } from "@/lib/utils";
interface Job {
id: string;
type: string;
status: string;
totalLeads: number;
emailsFound: number;
createdAt: string;
error?: string;
}
interface Stats {
totalLeads: number;
totalEmails: number;
avgHitRate: number;
totalJobs: number;
}
const TYPE_CONFIG: Record<string, { icon: typeof Building2; label: string; color: string }> = {
airscale: { icon: Building2, label: "AirScale", color: "text-blue-400" },
linkedin: { icon: Linkedin, label: "LinkedIn", color: "text-blue-500" },
"linkedin-enrich": { icon: Linkedin, label: "LinkedIn Enrich", color: "text-blue-400" },
serp: { icon: Search, label: "SERP", color: "text-purple-400" },
};
export default function ResultsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Stats>({ totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
loadJobs();
}, []);
const loadJobs = async () => {
setLoading(true);
try {
const res = await fetch("/api/jobs");
const data = await res.json() as { jobs: Job[]; stats: Stats };
setJobs(data.jobs || []);
setStats(data.stats || { totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
} catch {
toast.error("Failed to load job history");
} finally {
setLoading(false);
}
};
const deleteJob = async (id: string) => {
try {
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
setJobs(prev => prev.filter(j => j.id !== id));
toast.success("Job deleted");
} catch {
toast.error("Failed to delete job");
}
};
const downloadJob = (id: string) => {
window.open(`/api/export/${id}`, "_blank");
};
const hitRate = (job: Job) =>
job.totalLeads > 0 ? Math.round((job.emailsFound / job.totalLeads) * 100) : 0;
return (
<div className="space-y-6 max-w-6xl">
{/* 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">
<h1 className="text-2xl font-bold text-white">Results & History</h1>
<p className="text-gray-400 mt-1 text-sm">All past enrichment jobs and their results.</p>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total Jobs", value: stats.totalJobs, color: "text-white" },
{ label: "Total Leads", value: stats.totalLeads.toLocaleString(), color: "text-blue-400" },
{ label: "Emails Found", value: stats.totalEmails.toLocaleString(), color: "text-green-400" },
{ label: "Avg Hit Rate", value: `${stats.avgHitRate}%`, color: "text-purple-400" },
].map(stat => (
<Card key={stat.label} className="bg-[#111118] border-[#1e1e2e] p-5">
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</Card>
))}
</div>
{/* Jobs table */}
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1e1e2e]">
<h2 className="font-semibold text-white">Job History</h2>
<Button
variant="outline"
size="sm"
onClick={loadJobs}
className="border-[#2e2e3e] text-gray-300 hover:bg-[#1a1a28]"
>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" /> Refresh
</Button>
</div>
{loading ? (
<div className="p-6 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-[#1e1e2e]" />
))}
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No jobs yet"
description="Run an enrichment job from any of the pipeline tabs to see results here."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Job ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Started</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Emails</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Hit Rate</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job, idx) => {
const cfg = TYPE_CONFIG[job.type] || { icon: BarChart3, label: job.type, color: "text-gray-400" };
const Icon = cfg.icon;
return (
<tr
key={job.id}
className={cn(
"border-b border-[#1e1e2e]/50",
idx % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f1a]"
)}
>
<td className="px-4 py-3">
<div className={`flex items-center gap-2 ${cfg.color}`}>
<Icon className="w-4 h-4" />
<span className="text-xs font-medium">{cfg.label}</span>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">{job.id.slice(0, 12)}...</td>
<td className="px-4 py-3 text-xs text-gray-400">
{new Date(job.createdAt).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
})}
</td>
<td className="px-4 py-3">
<StatusBadge status={job.status === "complete" ? "complete" : job.status} />
</td>
<td className="px-4 py-3 text-white font-medium">{job.totalLeads.toLocaleString()}</td>
<td className="px-4 py-3 text-green-400 font-medium">{job.emailsFound.toLocaleString()}</td>
<td className="px-4 py-3">
<span className={cn(
"text-xs font-medium",
hitRate(job) >= 50 ? "text-green-400" : hitRate(job) >= 20 ? "text-yellow-400" : "text-red-400"
)}>
{hitRate(job)}%
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => downloadJob(job.id)}
disabled={job.status !== "complete"}
className="h-7 px-2 text-gray-400 hover:text-white hover:bg-[#1a1a28]"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteJob(job.id)}
className="h-7 px-2 text-gray-400 hover:text-red-400 hover:bg-red-500/5"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}

329
app/serp/page.tsx Normal file
View File

@@ -0,0 +1,329 @@
"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 { 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 { Search, ChevronRight } 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";
import { Checkbox } from "@/components/ui/checkbox";
const RESULT_OPTIONS = [10, 25, 50, 100, 200];
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" },
];
type Stage = "idle" | "running" | "done" | "failed";
export default function SerpPage() {
const [query, setQuery] = useState("");
const [numResults, setNumResults] = useState(50);
const [country, setCountry] = useState("de");
const [language, setLanguage] = useState("de");
const [filterSocial, setFilterSocial] = useState(true);
const [categories, setCategories] = 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();
// maxPages: since Google limits to 10 results/page, divide by 10
const maxPages = Math.max(1, Math.ceil(numResults / 10));
const startJob = async () => {
if (!query.trim()) return toast.error("Enter a search query");
if (!categories.length) return toast.error("Select at least one category");
setStage("running");
setResults([]);
setProgress({ current: 0, total: numResults, phase: "Searching Google..." });
try {
const res = await fetch("/api/jobs/serp-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
maxPages,
countryCode: country,
languageCode: language,
filterSocial,
categories,
}),
});
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: "serp", status: "running", progress: 0, total: numResults });
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...";
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[];
};
// Infer phase from progress
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Enriching domains with Anymailfinder...";
if (data.emailsFound > 0) phase = `Found ${data.emailsFound} emails so far...`;
setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, 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");
toast.success(`Done! Found ${data.emailsFound} emails`);
} else {
setStage("failed");
toast.error("Job failed. Check your API keys 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: "serp",
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;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
<div className="relative">
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
<Search className="w-4 h-4" />
<span>Tab 3</span>
<ChevronRight className="w-3 h-3" />
<span>Google SERP</span>
</div>
<h1 className="text-2xl font-bold text-white">SERP Email Enrichment</h1>
<p className="text-gray-400 mt-1 text-sm">
Scrape Google search results via Apify, extract domains, then find decision maker emails.
</p>
</div>
</div>
{/* Step 1: Configure */}
<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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span>
Search Configuration
</h2>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Search Term</Label>
<Input
placeholder='e.g. "Solaranlage Installateur Deutschland"'
value={query}
onChange={e => setQuery(e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Number of Results</Label>
<Select value={String(numResults)} onValueChange={v => setNumResults(Number(v))}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{RESULT_OPTIONS.map(n => (
<SelectItem key={n} value={String(n)} className="text-gray-300">{n} results</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">~{maxPages} page{maxPages > 1 ? "s" : ""} of Google</p>
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Country</Label>
<Select value={country} onValueChange={v => setCountry(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "Germany (DE)" },
{ value: "at", label: "Austria (AT)" },
{ value: "ch", label: "Switzerland (CH)" },
{ value: "us", label: "United States (US)" },
{ value: "gb", label: "United Kingdom (GB)" },
].map(c => (
<SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Language</Label>
<Select value={language} onValueChange={v => setLanguage(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "German" },
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
].map(l => (
<SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="filterSocial"
checked={filterSocial}
onCheckedChange={v => setFilterSocial(!!v)}
className="border-[#2e2e3e] mt-0.5"
/>
<div>
<label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer">
Exclude social media, directories & aggregators
</label>
<p className="text-xs text-gray-500 mt-0.5">
Filters out: LinkedIn, Facebook, Instagram, Yelp, Wikipedia, Xing, Twitter/X, YouTube, Google Maps
</p>
</div>
</div>
</Card>
{/* Step 2: Categories */}
<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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories
</h2>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setCategories(prev =>
prev.includes(opt.value) ? prev.filter(c => c !== opt.value) : [...prev, opt.value]
)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
categories.includes(opt.value)
? "bg-purple-500/20 text-purple-300 border-purple-500/40"
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-purple-500/30"
}`}
>
{opt.label}
</button>
))}
</div>
</Card>
{/* 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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span>
Run Pipeline
</h2>
{stage === "idle" || stage === "failed" ? (
<Button
onClick={startJob}
disabled={!query.trim() || !categories.length}
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-purple-500/25 transition-all"
>
Start SERP Search
</Button>
) : stage === "running" ? (
<ProgressCard
title="Running SERP pipeline..."
current={progress.current}
total={progress.total}
subtitle={progress.phase}
status="running"
/>
) : (
<ProgressCard
title="Pipeline complete"
current={emailsFound}
total={results.length}
subtitle={`Hit rate: ${hitRate}% · ${results.length} domains scraped`}
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">Results</h2>
<ExportButtons
rows={exportRows}
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found • ${hitRate}% hit rate`}
/>
</div>
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
</Card>
)}
{stage === "idle" && (
<EmptyState
icon={Search}
title="Configure and run a SERP search"
description="Enter a search term, select your target categories, and hit Start to find leads from Google search results."
/>
)}
</div>
);
}

244
app/settings/page.tsx Normal file
View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, 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 { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { CheckCircle2, XCircle, Loader2, ExternalLink, Eye, EyeOff } from "lucide-react";
interface Credential {
service: string;
label: string;
placeholder: string;
link: string;
linkLabel: string;
isTextarea?: boolean;
instructions?: string;
}
const CREDENTIALS: Credential[] = [
{
service: "anymailfinder",
label: "Anymailfinder API Key",
placeholder: "amf_...",
link: "https://anymailfinder.com/account",
linkLabel: "Get API key",
},
{
service: "apify",
label: "Apify API Token",
placeholder: "apify_api_...",
link: "https://console.apify.com/account/integrations",
linkLabel: "Get API token",
},
{
service: "vayne",
label: "Vayne API Token",
placeholder: "Bearer token from vayne.io dashboard",
link: "https://www.vayne.io",
linkLabel: "Vayne Dashboard",
isTextarea: true,
instructions: "Generate your API token in the API Settings section of your Vayne Dashboard at vayne.io. Use Bearer authentication — Vayne manages the LinkedIn session on their end.",
},
];
interface CredentialState {
value: string;
saved: boolean;
testing: boolean;
testResult: "ok" | "fail" | null;
show: boolean;
}
export default function SettingsPage() {
const [creds, setCreds] = useState<Record<string, CredentialState>>(() =>
Object.fromEntries(
CREDENTIALS.map(c => [c.service, { value: "", saved: false, testing: false, testResult: null, show: false }])
)
);
const [saving, setSaving] = useState(false);
// Load existing (masked) status
useEffect(() => {
fetch("/api/credentials")
.then(r => r.json())
.then((data: Record<string, boolean>) => {
setCreds(prev => {
const next = { ...prev };
for (const [k, v] of Object.entries(data)) {
if (next[k]) {
next[k] = { ...next[k], saved: v };
}
}
return next;
});
})
.catch(() => {});
}, []);
const update = (service: string, field: keyof CredentialState, val: unknown) => {
setCreds(prev => ({ ...prev, [service]: { ...prev[service], [field]: val } }));
};
const saveAll = async () => {
setSaving(true);
try {
const body: Record<string, string> = {};
for (const [service, state] of Object.entries(creds)) {
if (state.value) body[service] = state.value;
}
const res = await fetch("/api/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Save failed");
toast.success("Credentials saved successfully");
setCreds(prev => {
const next = { ...prev };
for (const service of Object.keys(body)) {
next[service] = { ...next[service], saved: true };
}
return next;
});
} catch {
toast.error("Failed to save credentials");
} finally {
setSaving(false);
}
};
const testConnection = async (service: string) => {
update(service, "testing", true);
update(service, "testResult", null);
try {
const res = await fetch(`/api/credentials/test?service=${service}`);
const data = await res.json() as { ok: boolean };
update(service, "testResult", data.ok ? "ok" : "fail");
if (data.ok) toast.success(`${service} connection verified`);
else toast.error(`${service} connection failed — check your key`);
} catch {
update(service, "testResult", "fail");
toast.error("Connection test failed");
} finally {
update(service, "testing", false);
}
};
return (
<div className="max-w-2xl space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-gray-400 mt-1 text-sm">
API credentials are encrypted with AES-256 and stored locally in SQLite.
</p>
</div>
{/* Credential cards */}
{CREDENTIALS.map(cred => {
const state = creds[cred.service];
return (
<Card key={cred.service} className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<Label className="text-white font-semibold text-base">{cred.label}</Label>
<div className="flex items-center gap-2 mt-1">
{state.saved ? (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Configured
</span>
) : (
<span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Not configured
</span>
)}
</div>
</div>
</div>
<a
href={cred.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
{cred.linkLabel} <ExternalLink className="w-3 h-3" />
</a>
</div>
{cred.instructions && (
<p className="text-xs text-gray-500 bg-[#0d0d18] rounded-lg p-3 border border-[#1e1e2e]">
{cred.instructions}
</p>
)}
<div className="relative">
{cred.isTextarea ? (
<Textarea
placeholder={cred.placeholder}
value={state.value}
onChange={e => update(cred.service, "value", e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20"
/>
) : (
<div className="relative">
<Input
type={state.show ? "text" : "password"}
placeholder={state.saved && !state.value ? "••••••••••••••••" : cred.placeholder}
value={state.value}
onChange={e => update(cred.service, "value", e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 pr-10"
/>
<button
type="button"
onClick={() => update(cred.service, "show", !state.show)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{state.show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
disabled={state.testing || (!state.saved && !state.value)}
onClick={() => testConnection(cred.service)}
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
>
{state.testing ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</>
) : "Test Connection"}
</Button>
{state.testResult === "ok" && (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Connected
</span>
)}
{state.testResult === "fail" && (
<span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Failed
</span>
)}
</div>
</Card>
);
})}
{/* Save button */}
<Button
disabled={saving}
onClick={saveAll}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
>
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save All Credentials"}
</Button>
</div>
);
}