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:
364
app/airscale/page.tsx
Normal file
364
app/airscale/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/api/credentials/route.ts
Normal file
51
app/api/credentials/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
50
app/api/credentials/test/route.ts
Normal file
50
app/api/credentials/test/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
app/api/export/[jobId]/route.ts
Normal file
42
app/api/export/[jobId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
app/api/jobs/[id]/route.ts
Normal file
16
app/api/jobs/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
47
app/api/jobs/[id]/status/route.ts
Normal file
47
app/api/jobs/[id]/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
106
app/api/jobs/airscale-enrich/route.ts
Normal file
106
app/api/jobs/airscale-enrich/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
167
app/api/jobs/linkedin-enrich/route.ts
Normal 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
41
app/api/jobs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
155
app/api/jobs/serp-enrich/route.ts
Normal file
155
app/api/jobs/serp-enrich/route.ts
Normal 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));
|
||||
}
|
||||
116
app/api/jobs/vayne-scrape/route.ts
Normal file
116
app/api/jobs/vayne-scrape/route.ts
Normal 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));
|
||||
}
|
||||
145
app/globals.css
145
app/globals.css
@@ -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; }
|
||||
|
||||
@@ -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
419
app/linkedin/page.tsx
Normal 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">
|
||||
{["1–10", "11–50", "51–200"].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>
|
||||
);
|
||||
}
|
||||
64
app/page.tsx
64
app/page.tsx
@@ -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
213
app/results/page.tsx
Normal 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
329
app/serp/page.tsx
Normal 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
244
app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user