Initial commit: LeadFlow lead generation platform

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

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

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

38
.dockerignore Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules
npm-debug.log*
# Next.js build output
.next
out
# Local database files
*.db
*.db-shm
*.db-wal
/data
# Environment files (injected at runtime)
.env.local
.env.*.local
# Development tools
.git
.gitignore
README.md
# Editor
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test files
__tests__
*.test.ts
*.spec.ts
coverage

2
.env.local.example Normal file
View File

@@ -0,0 +1,2 @@
APP_ENCRYPTION_SECRET=your-32-character-secret-here!!
DATABASE_URL=file:./leadflow.db

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# Normalize line endings to LF in the repo
* text=auto eol=lf
# Shell scripts must use LF (critical for Docker entrypoint)
*.sh text eol=lf
# Windows-specific overrides
*.bat text eol=crlf
*.cmd text eol=crlf

17
.gitignore vendored
View File

@@ -30,8 +30,13 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files — NEVER commit secrets
.env* .env
.env.local
.env.*.local
# Keep the example template
!.env.local.example
# vercel # vercel
.vercel .vercel
@@ -39,3 +44,11 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/lib/generated/prisma
# SQLite database files
*.db
*.db-shm
*.db-wal
/data/

73
Dockerfile Normal file
View File

@@ -0,0 +1,73 @@
# ──────────────────────────────────────────────
# Stage 1: Install dependencies
# ──────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
# Install native build tools needed for better-sqlite3
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
RUN npm ci
# ──────────────────────────────────────────────
# Stage 2: Build the application
# ──────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js (output: standalone)
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ──────────────────────────────────────────────
# Stage 3: Production runtime
# ──────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache python3 make g++
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Non-root user for security
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy Prisma files (schema + migrations + generated client)
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=builder /app/node_modules/@prisma/adapter-better-sqlite3 ./node_modules/@prisma/adapter-better-sqlite3
COPY --from=builder /app/node_modules/@prisma/driver-adapter-utils ./node_modules/@prisma/driver-adapter-utils
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
# Entrypoint script that runs migrations then starts the app
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
# Data directory for SQLite — must be a volume
RUN mkdir -p /data && chown nextjs:nodejs /data
USER nextjs
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]

135
README.md
View File

@@ -1,36 +1,131 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # LeadFlow — Lead Generation & Email Enrichment Platform
## Getting Started A unified platform for three lead-scraping pipelines with email enrichment via Anymailfinder.
First, run the development server: ---
## Tech Stack
- **Next.js 16** (App Router) + TypeScript
- **SQLite** via Prisma 7 + better-sqlite3
- **shadcn/ui** + Tailwind CSS
- **Anymailfinder API v5.1** — email enrichment (bulk JSON + individual search)
- **Vayne API** — LinkedIn Sales Navigator scraping
- **Apify** — Google SERP scraping
---
## Setup
### 1. Install dependencies
```bash
cd leadflow
npm install
```
### 2. Configure environment
Copy `.env.local.example` to `.env.local`:
```bash
cp .env.local.example .env.local
```
Edit `.env` and `.env.local`:
```env
APP_ENCRYPTION_SECRET=your-32-character-secret-here!!
DATABASE_URL=file:./leadflow.db
```
### 3. Run database migration
```bash
npx prisma migrate dev
```
### 4. Start the app
```bash ```bash
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3000](http://localhost:3000)
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ---
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## API Keys — Where to Get Them
## Learn More Go to **Settings** in the sidebar to enter and save credentials. All keys are AES-256 encrypted before storage.
To learn more about Next.js, take a look at the following resources: ### Anymailfinder
- Sign up at [anymailfinder.com](https://anymailfinder.com)
- Account → API → copy your key (format: starts with your account prefix)
- Pricing: 2 credits/valid decision maker email, 1 credit/person email
- Bulk API charges only when downloading results
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ### Apify
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - Sign up at [apify.com](https://apify.com)
- Console → Account → Integrations → API tokens
- The app uses the `apify/google-search-scraper` actor (pay-per-event)
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ### Vayne
- Sign up at [vayne.io](https://vayne.io)
- Dashboard → API Settings → generate token
- **Connect LinkedIn** in the Vayne dashboard — Vayne manages the LinkedIn session on their end
- No need to manually export cookies
## Deploy on Vercel ---
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ## Pipeline Workflows
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### Tab 1 — AirScale → Email
1. Export companies from AirScale as CSV
2. Upload → map domain column
3. Select decision maker categories
4. Start Enrichment → bulk API runs asynchronously
5. Export CSV/Excel
### Tab 2 — LinkedIn Sales Navigator → Email
1. Build search in Sales Navigator, copy URL
2. Paste URL + set max results → Start Scrape
3. Vayne scrapes profiles (polls until done)
4. Select profiles → Enrich with Emails
5. Export results
### Tab 3 — SERP → Email
1. Enter search term (e.g. `"Solaranlage Installateur Deutschland"`)
2. Set country/language, enable social filter
3. Select decision maker categories
4. Start → Apify scrapes Google, domains extracted, then bulk enriched
5. Export results
---
## How Anymailfinder Bulk Works
1. All domains submitted as one POST to `/v5.1/bulk/json`
2. Poll status every 5s until `completed`
3. Download results once (credits charged at download, not submission)
4. Speed: ~1,000 domains per 5 minutes
---
## Database
SQLite at `./leadflow.db`. Inspect with:
```bash
npx prisma studio
```
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "API key not configured" | Add key in Settings |
| Job stuck at "running" | Check server console (`npm run dev` terminal) |
| Prisma errors on build | Run `npx prisma generate && npm run build` |

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,129 +1,30 @@
@import "tailwindcss"; @import "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 { :root {
--background: oklch(1 0 0); --background: #0a0a0f;
--foreground: oklch(0.145 0 0); --card: #111118;
--card: oklch(1 0 0); --border: #1e1e2e;
--card-foreground: oklch(0.145 0 0); --primary: #3b82f6;
--popover: oklch(1 0 0); --secondary: #8b5cf6;
--popover-foreground: oklch(0.145 0 0); --success: #22c55e;
--primary: oklch(0.205 0 0); --warning: #f59e0b;
--primary-foreground: oklch(0.985 0 0); --error: #ef4444;
--secondary: oklch(0.97 0 0); --foreground: #f0f0f5;
--secondary-foreground: oklch(0.205 0 0); --muted: #6b7280;
--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);
} }
.dark { * {
--background: oklch(0.145 0 0); border-color: var(--border);
--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);
} }
@layer base { body {
* { background-color: var(--background);
@apply border-border outline-ring/50; color: var(--foreground);
} font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
} }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0a0a0f; }
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }

View File

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

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

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

View File

@@ -1,65 +1,5 @@
import Image from "next/image"; import { redirect } from "next/navigation";
export default function Home() { export default function Home() {
return ( redirect("/airscale");
<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>
);
} }

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

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

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

@@ -0,0 +1,329 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProgressCard } from "@/components/shared/ProgressCard";
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
import { ExportButtons } from "@/components/shared/ExportButtons";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
import { Search, ChevronRight } from "lucide-react";
import { useAppStore } from "@/lib/store";
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
import type { ExportRow } from "@/lib/utils/csv";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
const RESULT_OPTIONS = [10, 25, 50, 100, 200];
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
{ value: "ceo", label: "CEO / Owner / Founder" },
{ value: "engineering", label: "Engineering" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "operations", label: "Operations" },
{ value: "finance", label: "Finance" },
{ value: "hr", label: "HR" },
{ value: "it", label: "IT" },
{ value: "buyer", label: "Procurement" },
{ value: "logistics", label: "Logistics" },
];
type Stage = "idle" | "running" | "done" | "failed";
export default function SerpPage() {
const [query, setQuery] = useState("");
const [numResults, setNumResults] = useState(50);
const [country, setCountry] = useState("de");
const [language, setLanguage] = useState("de");
const [filterSocial, setFilterSocial] = useState(true);
const [categories, setCategories] = useState<DecisionMakerCategory[]>(["ceo"]);
const [stage, setStage] = useState<Stage>("idle");
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
const [results, setResults] = useState<ResultRow[]>([]);
const { addJob, updateJob, removeJob } = useAppStore();
// maxPages: since Google limits to 10 results/page, divide by 10
const maxPages = Math.max(1, Math.ceil(numResults / 10));
const startJob = async () => {
if (!query.trim()) return toast.error("Enter a search query");
if (!categories.length) return toast.error("Select at least one category");
setStage("running");
setResults([]);
setProgress({ current: 0, total: numResults, phase: "Searching Google..." });
try {
const res = await fetch("/api/jobs/serp-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
maxPages,
countryCode: country,
languageCode: language,
filterSocial,
categories,
}),
});
const data = await res.json() as { jobId?: string; error?: string };
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
setJobId(data.jobId);
addJob({ id: data.jobId, type: "serp", status: "running", progress: 0, total: numResults });
pollJob(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start");
setStage("failed");
}
};
const pollJob = (id: string) => {
let phase = "Searching Google...";
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/jobs/${id}/status`);
const data = await res.json() as {
status: string; totalLeads: number; emailsFound: number;
results: ResultRow[];
};
// Infer phase from progress
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Enriching domains with Anymailfinder...";
if (data.emailsFound > 0) phase = `Found ${data.emailsFound} emails so far...`;
setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, phase });
if (data.results?.length) setResults(data.results);
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
if (data.status === "complete" || data.status === "failed") {
clearInterval(interval);
removeJob(id);
setResults(data.results || []);
if (data.status === "complete") {
setStage("done");
toast.success(`Done! Found ${data.emailsFound} emails`);
} else {
setStage("failed");
toast.error("Job failed. Check your API keys in Settings.");
}
}
} catch {
clearInterval(interval);
setStage("failed");
}
}, 2000);
};
const exportRows: ExportRow[] = results.map(r => ({
company_name: r.companyName,
domain: r.domain,
contact_name: r.contactName,
contact_title: r.contactTitle,
email: r.email,
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
source_tab: "serp",
job_id: jobId || "",
found_at: new Date().toISOString(),
}));
const emailsFound = results.filter(r => r.email).length;
const hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
<div className="relative">
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
<Search className="w-4 h-4" />
<span>Tab 3</span>
<ChevronRight className="w-3 h-3" />
<span>Google SERP</span>
</div>
<h1 className="text-2xl font-bold text-white">SERP Email Enrichment</h1>
<p className="text-gray-400 mt-1 text-sm">
Scrape Google search results via Apify, extract domains, then find decision maker emails.
</p>
</div>
</div>
{/* Step 1: Configure */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span>
Search Configuration
</h2>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Search Term</Label>
<Input
placeholder='e.g. "Solaranlage Installateur Deutschland"'
value={query}
onChange={e => setQuery(e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Number of Results</Label>
<Select value={String(numResults)} onValueChange={v => setNumResults(Number(v))}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{RESULT_OPTIONS.map(n => (
<SelectItem key={n} value={String(n)} className="text-gray-300">{n} results</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">~{maxPages} page{maxPages > 1 ? "s" : ""} of Google</p>
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Country</Label>
<Select value={country} onValueChange={v => setCountry(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "Germany (DE)" },
{ value: "at", label: "Austria (AT)" },
{ value: "ch", label: "Switzerland (CH)" },
{ value: "us", label: "United States (US)" },
{ value: "gb", label: "United Kingdom (GB)" },
].map(c => (
<SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Language</Label>
<Select value={language} onValueChange={v => setLanguage(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "German" },
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
].map(l => (
<SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="filterSocial"
checked={filterSocial}
onCheckedChange={v => setFilterSocial(!!v)}
className="border-[#2e2e3e] mt-0.5"
/>
<div>
<label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer">
Exclude social media, directories & aggregators
</label>
<p className="text-xs text-gray-500 mt-0.5">
Filters out: LinkedIn, Facebook, Instagram, Yelp, Wikipedia, Xing, Twitter/X, YouTube, Google Maps
</p>
</div>
</div>
</Card>
{/* Step 2: Categories */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories
</h2>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setCategories(prev =>
prev.includes(opt.value) ? prev.filter(c => c !== opt.value) : [...prev, opt.value]
)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
categories.includes(opt.value)
? "bg-purple-500/20 text-purple-300 border-purple-500/40"
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-purple-500/30"
}`}
>
{opt.label}
</button>
))}
</div>
</Card>
{/* Run */}
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span>
Run Pipeline
</h2>
{stage === "idle" || stage === "failed" ? (
<Button
onClick={startJob}
disabled={!query.trim() || !categories.length}
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-purple-500/25 transition-all"
>
Start SERP Search
</Button>
) : stage === "running" ? (
<ProgressCard
title="Running SERP pipeline..."
current={progress.current}
total={progress.total}
subtitle={progress.phase}
status="running"
/>
) : (
<ProgressCard
title="Pipeline complete"
current={emailsFound}
total={results.length}
subtitle={`Hit rate: ${hitRate}% · ${results.length} domains scraped`}
status="complete"
/>
)}
</Card>
{/* Results */}
{results.length > 0 && (
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-semibold text-white">Results</h2>
<ExportButtons
rows={exportRows}
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found • ${hitRate}% hit rate`}
/>
</div>
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
</Card>
)}
{stage === "idle" && (
<EmptyState
icon={Search}
title="Configure and run a SERP search"
description="Enter a search term, select your target categories, and hit Start to find leads from Google search results."
/>
)}
</div>
);
}

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

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

View File

@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Building2, Linkedin, Search, BarChart3, Settings, Zap, ChevronLeft, ChevronRight } from "lucide-react";
import { useAppStore } from "@/lib/store";
import { useEffect, useState } from "react";
const navItems = [
{ href: "/airscale", icon: Building2, label: "AirScale → Email", color: "text-blue-400" },
{ href: "/linkedin", icon: Linkedin, label: "LinkedIn → Email", color: "text-blue-500" },
{ href: "/serp", icon: Search, label: "SERP → Email", color: "text-purple-400" },
{ href: "/results", icon: BarChart3, label: "Results & History", color: "text-green-400" },
{ href: "/settings", icon: Settings, label: "Settings", color: "text-gray-400" },
];
interface CredentialStatus {
anymailfinder: boolean;
apify: boolean;
vayne: boolean;
}
export function Sidebar() {
const pathname = usePathname();
const { sidebarCollapsed, setSidebarCollapsed } = useAppStore();
const [creds, setCreds] = useState<CredentialStatus>({ anymailfinder: false, apify: false, vayne: false });
useEffect(() => {
fetch("/api/credentials")
.then(r => r.json())
.then(d => setCreds(d))
.catch(() => {});
}, []);
return (
<aside
className={cn(
"relative flex flex-col border-r border-[#1e1e2e] bg-[#111118] transition-all duration-300",
sidebarCollapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className="flex items-center gap-2 px-4 py-5 border-b border-[#1e1e2e]">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<Zap className="w-4 h-4 text-white" />
</div>
{!sidebarCollapsed && (
<span className="font-bold text-lg tracking-tight text-white">LeadFlow</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 px-2 py-4 space-y-1">
{navItems.map(({ href, icon: Icon, label, color }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all",
active
? "bg-[#1e1e2e] text-white"
: "text-gray-400 hover:text-white hover:bg-[#1a1a28]"
)}
>
<Icon className={cn("w-5 h-5 flex-shrink-0", active ? color : "")} />
{!sidebarCollapsed && <span>{label}</span>}
</Link>
);
})}
</nav>
{/* Credential status */}
{!sidebarCollapsed && (
<div className="px-4 py-4 border-t border-[#1e1e2e] space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">API Status</p>
{[
{ key: "anymailfinder", label: "Anymailfinder" },
{ key: "apify", label: "Apify" },
{ key: "vayne", label: "Vayne" },
].map(({ key, label }) => (
<div key={key} className="flex items-center gap-2">
<span className={cn("w-2 h-2 rounded-full flex-shrink-0", creds[key as keyof CredentialStatus] ? "bg-green-500" : "bg-red-500")} />
<span className="text-xs text-gray-400">{label}</span>
</div>
))}
</div>
)}
{/* Collapse toggle */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="absolute -right-3 top-6 w-6 h-6 rounded-full bg-[#1e1e2e] border border-[#2e2e3e] flex items-center justify-center hover:bg-[#2e2e3e] transition-colors z-10"
>
{sidebarCollapsed ? <ChevronRight className="w-3 h-3 text-gray-400" /> : <ChevronLeft className="w-3 h-3 text-gray-400" />}
</button>
</aside>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { usePathname } from "next/navigation";
import { useAppStore } from "@/lib/store";
import { Activity } from "lucide-react";
const BREADCRUMBS: Record<string, string> = {
"/airscale": "AirScale → Email",
"/linkedin": "LinkedIn → Email",
"/serp": "SERP → Email",
"/results": "Results & History",
"/settings": "Settings",
};
export function TopBar() {
const pathname = usePathname();
const { activeJobs } = useAppStore();
const runningJobs = activeJobs.filter(j => j.status === "running").length;
const label = BREADCRUMBS[pathname] || "Dashboard";
return (
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">LeadFlow</span>
<span className="text-gray-600">/</span>
<span className="text-white font-medium">{label}</span>
</div>
{runningJobs > 0 && (
<div className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-3 py-1">
<Activity className="w-3.5 h-3.5 text-blue-400 animate-pulse" />
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,21 @@
import { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: React.ReactNode;
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-[#1e1e2e] flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-base font-semibold text-white mb-2">{title}</h3>
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p>
{action}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { Button } from "@/components/ui/button";
import { Download, FileSpreadsheet } from "lucide-react";
import { exportToCSV, exportToExcel, type ExportRow } from "@/lib/utils/csv";
interface ExportButtonsProps {
rows: ExportRow[];
filename: string;
disabled?: boolean;
summary?: string;
}
export function ExportButtons({ rows, filename, disabled, summary }: ExportButtonsProps) {
return (
<div className="flex items-center gap-3 flex-wrap">
{summary && <span className="text-sm text-gray-400">{summary}</span>}
<Button
variant="outline"
size="sm"
disabled={disabled || rows.length === 0}
onClick={() => exportToCSV(rows, `${filename}.csv`)}
className="border-[#2e2e3e] hover:border-blue-500/50 hover:bg-blue-500/5 text-gray-300"
>
<Download className="w-4 h-4 mr-1.5" /> Download CSV
</Button>
<Button
variant="outline"
size="sm"
disabled={disabled || rows.length === 0}
onClick={() => exportToExcel(rows, `${filename}.xlsx`)}
className="border-[#2e2e3e] hover:border-purple-500/50 hover:bg-purple-500/5 text-gray-300"
>
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> Download Excel
</Button>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { Upload, FileText } from "lucide-react";
interface FileDropZoneProps {
onFile: (content: string, filename: string) => void;
accept?: string;
label?: string;
}
export function FileDropZone({ onFile, accept = ".csv", label = "Drop your CSV file here" }: FileDropZoneProps) {
const [dragging, setDragging] = useState(false);
const [filename, setFilename] = useState<string | null>(null);
const processFile = useCallback((file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
onFile(e.target?.result as string, file.name);
setFilename(file.name);
};
reader.readAsText(file);
}, [onFile]);
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
}, [processFile]);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
};
return (
<label
className={cn(
"relative flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-xl cursor-pointer transition-all",
dragging
? "border-blue-500 bg-blue-500/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
: filename
? "border-green-500/50 bg-green-500/5"
: "border-[#2e2e3e] bg-[#0d0d18] hover:border-blue-500/50 hover:bg-blue-500/5"
)}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<input type="file" accept={accept} className="sr-only" onChange={onInputChange} />
{filename ? (
<div className="flex flex-col items-center gap-2">
<FileText className="w-8 h-8 text-green-400" />
<span className="text-sm text-green-400 font-medium">{filename}</span>
<span className="text-xs text-gray-500">Click to replace</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-center">
<Upload className="w-8 h-8 text-gray-500" />
<span className="text-sm text-gray-300">{label}</span>
<span className="text-xs text-gray-500">Click to browse or drag & drop</span>
</div>
)}
</label>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
interface ProgressCardProps {
title: string;
current: number;
total: number;
subtitle?: string;
status?: "running" | "complete" | "failed" | "idle";
}
export function ProgressCard({ title, current, total, subtitle, status = "running" }: ProgressCardProps) {
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div className="bg-[#111118] border border-[#1e1e2e] rounded-xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-white">{title}</h3>
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
</div>
<StatusBadge status={status} />
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-400">
<span>{current.toLocaleString()} / {total.toLocaleString()}</span>
<span>{pct}%</span>
</div>
<div className="h-2 bg-[#1e1e2e] rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
status === "failed"
? "bg-red-500"
: status === "complete"
? "bg-green-500"
: "bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse"
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
);
}
export function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; color: string; dot: string }> = {
running: { label: "Running", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", dot: "bg-blue-400 animate-pulse" },
complete: { label: "Complete", color: "bg-green-500/10 text-green-400 border-green-500/20", dot: "bg-green-400" },
failed: { label: "Failed", color: "bg-red-500/10 text-red-400 border-red-500/20", dot: "bg-red-400" },
pending: { label: "Pending", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", dot: "bg-yellow-400" },
idle: { label: "Idle", color: "bg-gray-500/10 text-gray-400 border-gray-500/20", dot: "bg-gray-400" },
};
const c = config[status] || config.idle;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${c.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{c.label}
</span>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { CheckCircle2, XCircle, AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
export interface ResultRow {
id: string;
companyName?: string;
domain?: string;
contactName?: string;
contactTitle?: string;
email?: string;
confidence?: number;
linkedinUrl?: string;
status?: string;
selected?: boolean;
}
interface ResultsTableProps {
rows: ResultRow[];
loading?: boolean;
selectable?: boolean;
onSelectionChange?: (ids: string[]) => void;
extraColumns?: Array<{ key: string; label: string }>;
}
function EmailStatusIcon({ email, confidence }: { email?: string; confidence?: number }) {
if (!email) return <XCircle className="w-4 h-4 text-red-400" />;
if (confidence && confidence < 0.7) return <AlertCircle className="w-4 h-4 text-yellow-400" />;
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
}
export function ResultsTable({ rows, loading, selectable, onSelectionChange, extraColumns }: ResultsTableProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
const toggleSelect = (id: string) => {
const next = new Set(selected);
next.has(id) ? next.delete(id) : next.add(id);
setSelected(next);
onSelectionChange?.(Array.from(next));
};
const toggleAll = () => {
if (selected.size === rows.length) {
setSelected(new Set());
onSelectionChange?.([]);
} else {
const all = new Set(rows.map(r => r.id));
setSelected(all);
onSelectionChange?.(Array.from(all));
}
};
if (loading) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full bg-[#1e1e2e]" />
))}
</div>
);
}
if (rows.length === 0) return null;
return (
<div className="overflow-x-auto rounded-xl border border-[#1e1e2e]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
{selectable && (
<th className="w-10 px-3 py-3">
<input
type="checkbox"
checked={selected.size === rows.length && rows.length > 0}
onChange={toggleAll}
className="rounded border-[#2e2e3e] bg-[#1e1e2e]"
/>
</th>
)}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Company</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Domain</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Contact</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Title</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Email</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Confidence</th>
{extraColumns?.map(col => (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr
key={row.id}
className={cn(
"border-b border-[#1e1e2e]/50 transition-colors",
idx % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f1a]",
selectable && "cursor-pointer hover:bg-[#1a1a28]",
selectable && selected.has(row.id) && "bg-blue-500/5"
)}
onClick={() => selectable && toggleSelect(row.id)}
>
{selectable && (
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => toggleSelect(row.id)}
className="rounded border-[#2e2e3e] bg-[#1e1e2e]"
/>
</td>
)}
<td className="px-4 py-2.5 text-white font-medium">{row.companyName || "—"}</td>
<td className="px-4 py-2.5 text-gray-400 font-mono text-xs">{row.domain || "—"}</td>
<td className="px-4 py-2.5 text-gray-300">{row.contactName || "—"}</td>
<td className="px-4 py-2.5 text-gray-400 text-xs">{row.contactTitle || "—"}</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5">
<EmailStatusIcon email={row.email} confidence={row.confidence} />
<span className="text-gray-300 font-mono text-xs">{row.email || "—"}</span>
</div>
</td>
<td className="px-4 py-2.5">
{row.confidence !== undefined ? (
<span className={cn("text-xs font-medium", row.confidence >= 0.8 ? "text-green-400" : row.confidence >= 0.6 ? "text-yellow-400" : "text-red-400")}>
{Math.round(row.confidence * 100)}%
</span>
) : "—"}
</td>
{extraColumns?.map(col => (
<td key={col.key} className="px-4 py-2.5 text-gray-400 text-xs">
{((row as unknown) as Record<string, string>)[col.key] || "—"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, KeyboardEvent } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface RoleChipsInputProps {
value: string[];
onChange: (roles: string[]) => void;
label?: string;
}
export function RoleChipsInput({ value, onChange, label = "Target Roles" }: RoleChipsInputProps) {
const [input, setInput] = useState("");
const add = (role: string) => {
const trimmed = role.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInput("");
};
const remove = (role: string) => onChange(value.filter(r => r !== role));
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
add(input);
} else if (e.key === "Backspace" && !input && value.length > 0) {
remove(value[value.length - 1]);
}
};
return (
<div>
{label && <label className="block text-sm font-medium text-gray-300 mb-2">{label}</label>}
<div className="min-h-[44px] flex flex-wrap gap-2 p-2.5 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg focus-within:border-blue-500 transition-colors">
{value.map(role => (
<span
key={role}
className="flex items-center gap-1 bg-blue-500/20 text-blue-300 border border-blue-500/30 rounded-md px-2.5 py-0.5 text-sm"
>
{role}
<button onClick={() => remove(role)} className="hover:text-white transition-colors">
<X className="w-3 h-3" />
</button>
</span>
))}
<input
className="flex-1 min-w-[120px] bg-transparent text-sm text-white outline-none placeholder:text-gray-600"
placeholder="Add role, press Enter..."
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => input && add(input)}
/>
</div>
</div>
);
}

76
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

103
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

157
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

20
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

20
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

201
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

49
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
leadflow:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- leadflow_data:/data
environment:
# Database — stored in /data volume for persistence
DATABASE_URL: file:/data/leadflow.db
# 32-character secret for AES-256 credential encryption
APP_ENCRYPTION_SECRET: ${APP_ENCRYPTION_SECRET:-change-me-in-production-32chars}
NODE_ENV: production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/credentials"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes:
leadflow_data:
driver: local

11
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Run Prisma migrations on every startup (idempotent)
echo "Running database migrations..."
DATABASE_URL="${DATABASE_URL:-file:/data/leadflow.db}" \
node node_modules/prisma/build/index.js migrate deploy \
--schema ./prisma/schema.prisma 2>&1 || echo "Migration warning (may already be up to date)"
echo "Starting LeadFlow..."
exec node server.js

17
lib/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PrismaClient } from "@prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
// Prisma 7 requires a driver adapter for SQLite connections.
function createPrisma() {
const url = process.env.DATABASE_URL || "file:./leadflow.db";
const adapter = new PrismaBetterSqlite3({ url });
return new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
}
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? createPrisma();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,232 @@
// Anymailfinder API v5.1
// Docs: https://anymailfinder.com/api
// Auth: Authorization: YOUR_API_KEY (header)
// No rate limits on individual searches.
// Bulk API processes ~1,000 rows per 5 minutes asynchronously.
import axios from "axios";
const BASE_URL = "https://api.anymailfinder.com/v5.1";
export type DecisionMakerCategory =
| "ceo" | "engineering" | "finance" | "hr" | "it"
| "logistics" | "marketing" | "operations" | "buyer" | "sales";
export interface DecisionMakerResult {
decision_maker_category: string | null;
email: string | null;
email_status: "valid" | "risky" | "not_found" | "blacklisted";
person_full_name: string | null;
person_job_title: string | null;
person_linkedin_url: string | null;
valid_email: string | null;
domain?: string;
}
export interface BulkSearchResult {
domain: string;
email: string | null;
email_status: string;
person_full_name: string | null;
person_job_title: string | null;
valid_email: string | null;
}
// ─── Individual search (used for small batches / LinkedIn enrichment) ─────────
export async function searchDecisionMakerByDomain(
domain: string,
categories: DecisionMakerCategory[],
apiKey: string
): Promise<DecisionMakerResult> {
const response = await axios.post(
`${BASE_URL}/find-email/decision-maker`,
{ domain, decision_maker_category: categories },
{
headers: { Authorization: apiKey, "Content-Type": "application/json" },
timeout: 180000,
}
);
return { ...response.data, domain };
}
// ─── Bulk JSON search (preferred for large domain lists) ────────────────────
export interface BulkJobStatus {
id: string;
status: "queued" | "running" | "completed" | "failed" | "paused" | "on_deck";
counts: {
total: number;
found_valid: number;
found_unknown: number;
not_found: number;
failed: number;
};
}
/**
* Submit a bulk decision-maker search via the JSON API.
* Returns a searchId to poll for completion.
*/
export async function submitBulkDecisionMakerSearch(
domains: string[],
category: DecisionMakerCategory,
apiKey: string,
fileName?: string
): Promise<string> {
// Build data array: header row + data rows
const data: string[][] = [
["domain"],
...domains.map(d => [d]),
];
const response = await axios.post(
`${BASE_URL}/bulk/json`,
{
data,
domain_field_index: 0,
decision_maker_category: category,
file_name: fileName || `leadflow-${Date.now()}`,
},
{
headers: { Authorization: apiKey, "Content-Type": "application/json" },
timeout: 30000,
}
);
return response.data.id as string;
}
/**
* Submit a bulk person name search via the JSON API.
* Used for LinkedIn enrichment where we have names + domains.
*/
export async function submitBulkPersonSearch(
leads: Array<{ domain: string; firstName: string; lastName: string }>,
apiKey: string,
fileName?: string
): Promise<string> {
const data: string[][] = [
["domain", "first_name", "last_name"],
...leads.map(l => [l.domain, l.firstName, l.lastName]),
];
const response = await axios.post(
`${BASE_URL}/bulk/json`,
{
data,
domain_field_index: 0,
first_name_field_index: 1,
last_name_field_index: 2,
file_name: fileName || `leadflow-${Date.now()}`,
},
{
headers: { Authorization: apiKey, "Content-Type": "application/json" },
timeout: 30000,
}
);
return response.data.id as string;
}
export async function getBulkSearchStatus(
searchId: string,
apiKey: string
): Promise<BulkJobStatus> {
const response = await axios.get(`${BASE_URL}/bulk/${searchId}`, {
headers: { Authorization: apiKey },
timeout: 15000,
});
return response.data;
}
/**
* Download bulk search results as JSON array.
* IMPORTANT: Credits are charged on first download.
*/
export async function downloadBulkResults(
searchId: string,
apiKey: string
): Promise<Array<Record<string, string>>> {
const response = await axios.get(`${BASE_URL}/bulk/${searchId}/download`, {
params: { download_as: "json_arr" },
headers: { Authorization: apiKey },
timeout: 60000,
});
return response.data as Array<Record<string, string>>;
}
/**
* High-level bulk enrichment: submit → poll → download → return results.
* Uses the Bulk JSON API for efficiency (1,000 rows/5 min).
* Calls onProgress with status updates while waiting.
*/
export async function bulkSearchDomains(
domains: string[],
categories: DecisionMakerCategory[],
apiKey: string,
onProgress?: (completed: number, total: number, result?: DecisionMakerResult) => Promise<void> | void
): Promise<DecisionMakerResult[]> {
if (domains.length === 0) return [];
// Use the primary category (first in list) for bulk search.
// Anymailfinder bulk API takes one category at a time.
const primaryCategory = categories[0] || "ceo";
// 1. Submit bulk job
const searchId = await submitBulkDecisionMakerSearch(
domains,
primaryCategory,
apiKey,
`leadflow-bulk-${Date.now()}`
);
// 2. Poll until complete (~1,000 rows per 5 min)
let status: BulkJobStatus;
do {
await sleep(5000);
status = await getBulkSearchStatus(searchId, apiKey);
const processed = (status.counts?.found_valid || 0) + (status.counts?.not_found || 0) + (status.counts?.found_unknown || 0);
onProgress?.(processed, status.counts?.total || domains.length);
} while (status.status !== "completed" && status.status !== "failed");
if (status.status === "failed") {
throw new Error(`Anymailfinder bulk search failed (id: ${searchId})`);
}
// 3. Download results
const rows = await downloadBulkResults(searchId, apiKey);
// 4. Normalize to DecisionMakerResult[]
return rows.map(row => {
const email = row["email"] || row["Email"] || null;
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
const validEmail = emailStatus === "valid" ? email : null;
return {
domain: row["domain"] || row["Domain"] || "",
decision_maker_category: primaryCategory,
email,
email_status: emailStatus as DecisionMakerResult["email_status"],
valid_email: validEmail,
person_full_name: row["person_full_name"] || row["Full Name"] || null,
person_job_title: row["person_job_title"] || row["Job Title"] || null,
person_linkedin_url: row["person_linkedin_url"] || row["LinkedIn URL"] || null,
};
});
}
export async function getRemainingCredits(apiKey: string): Promise<number | null> {
try {
// Try account endpoint (may not be documented publicly, returns null if unavailable)
const response = await axios.get(`${BASE_URL}/account`, {
headers: { Authorization: apiKey },
timeout: 10000,
});
return response.data?.credits_remaining ?? null;
} catch {
return null;
}
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

84
lib/services/apify.ts Normal file
View File

@@ -0,0 +1,84 @@
import axios from "axios";
import { extractDomainFromUrl } from "@/lib/utils/domains";
const BASE_URL = "https://api.apify.com/v2";
const ACTOR_ID = "apify~google-search-scraper";
export interface SerpResult {
title: string;
url: string;
domain: string;
description: string;
position: number;
}
export async function runGoogleSerpScraper(
query: string,
maxPages: number,
countryCode: string,
languageCode: string,
apiToken: string
): Promise<string> {
// maxPages: to get ~N results, set maxPagesPerQuery = ceil(N/10)
const response = await axios.post(
`${BASE_URL}/acts/${ACTOR_ID}/runs`,
{
queries: query,
maxPagesPerQuery: maxPages,
countryCode: countryCode.toUpperCase(),
languageCode: languageCode.toLowerCase(),
},
{
params: { token: apiToken },
headers: { "Content-Type": "application/json" },
timeout: 30000,
}
);
return response.data.data.id;
}
export async function pollRunStatus(
runId: string,
apiToken: string
): Promise<{ status: string; defaultDatasetId: string }> {
const response = await axios.get(`${BASE_URL}/actor-runs/${runId}`, {
params: { token: apiToken },
timeout: 15000,
});
const { status, defaultDatasetId } = response.data.data;
return { status, defaultDatasetId };
}
export async function fetchDatasetItems(
datasetId: string,
apiToken: string
): Promise<SerpResult[]> {
const response = await axios.get(
`${BASE_URL}/datasets/${datasetId}/items`,
{
params: { token: apiToken, format: "json" },
timeout: 30000,
}
);
const items = response.data as Array<{
query?: string;
organicResults?: Array<{ title: string; url: string; description: string; position: number }>;
}>;
const results: SerpResult[] = [];
for (const item of items) {
if (item.organicResults) {
for (const r of item.organicResults) {
results.push({
title: r.title || "",
url: r.url || "",
domain: extractDomainFromUrl(r.url || ""),
description: r.description || "",
position: r.position || 0,
});
}
}
}
return results;
}

153
lib/services/vayne.ts Normal file
View File

@@ -0,0 +1,153 @@
// Vayne API integration
// Docs: https://www.vayne.io (OpenAPI spec available at /api endpoint)
// Auth: Authorization: Bearer <api_token>
// Base URL: https://www.vayne.io
//
// Flow:
// 1. POST /api/orders with { url, limit, name, email_enrichment: false, export_format: "simple" }
// 2. Poll GET /api/orders/{id} until scraping_status === "finished" | "failed"
// 3. POST /api/orders/{id}/export with { export_format: "simple" }
// 4. Poll GET /api/orders/{id} until exports[0].status === "completed"
// 5. Download CSV from exports[0].file_url (S3 presigned URL)
import axios from "axios";
import Papa from "papaparse";
const BASE_URL = "https://www.vayne.io";
export interface VayneOrder {
id: number;
name: string;
order_type: string;
scraping_status: "initialization" | "pending" | "segmenting" | "scraping" | "finished" | "failed";
limit: number;
scraped: number;
created_at: string;
exports?: Array<{ status: "completed" | "pending" | "not_started"; file_url?: string }>;
}
export interface LeadProfile {
firstName: string;
lastName: string;
fullName: string;
title: string;
company: string;
companyDomain: string;
linkedinUrl: string;
location: string;
}
export async function createOrder(
salesNavUrl: string,
maxResults: number,
apiToken: string,
orderName?: string
): Promise<VayneOrder> {
const response = await axios.post(
`${BASE_URL}/api/orders`,
{
url: salesNavUrl,
limit: maxResults,
name: orderName || `LeadFlow-${Date.now()}`,
email_enrichment: false,
export_format: "simple",
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
timeout: 30000,
}
);
return response.data.order;
}
export async function getOrderStatus(
orderId: number,
apiToken: string
): Promise<VayneOrder> {
const response = await axios.get(`${BASE_URL}/api/orders/${orderId}`, {
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 15000,
});
return response.data.order;
}
export async function triggerExport(
orderId: number,
apiToken: string
): Promise<VayneOrder> {
const response = await axios.post(
`${BASE_URL}/api/orders/${orderId}/export`,
{ export_format: "simple" },
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
timeout: 15000,
}
);
return response.data.order;
}
export async function downloadOrderCSV(
fileUrl: string
): Promise<LeadProfile[]> {
const response = await axios.get(fileUrl, {
timeout: 60000,
responseType: "text",
});
return parseVayneCSV(response.data);
}
function parseVayneCSV(csvContent: string): LeadProfile[] {
const { data } = Papa.parse<Record<string, string>>(csvContent, {
header: true,
skipEmptyLines: true,
});
return data.map((row) => {
// Vayne simple format columns (may vary; handle common variants)
const fullName = row["Name"] || row["Full Name"] || row["full_name"] || "";
const nameParts = fullName.trim().split(/\s+/);
const firstName = nameParts[0] || "";
const lastName = nameParts.slice(1).join(" ") || "";
// Extract domain from company URL or website column
const companyUrl = row["Company URL"] || row["company_url"] || row["Website"] || "";
let companyDomain = "";
if (companyUrl) {
try {
const u = new URL(companyUrl.startsWith("http") ? companyUrl : `https://${companyUrl}`);
companyDomain = u.hostname.replace(/^www\./i, "");
} catch {
companyDomain = companyUrl.replace(/^www\./i, "").split("/")[0];
}
}
return {
firstName,
lastName,
fullName,
title: row["Job Title"] || row["Title"] || row["title"] || "",
company: row["Company"] || row["Company Name"] || row["company"] || "",
companyDomain,
linkedinUrl: row["LinkedIn URL"] || row["linkedin_url"] || row["Profile URL"] || "",
location: row["Location"] || row["location"] || "",
};
});
}
export async function checkLinkedInAuth(apiToken: string): Promise<boolean> {
try {
const response = await axios.get(`${BASE_URL}/api/linkedin/status`, {
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 10000,
});
return response.data?.connected === true;
} catch {
return false;
}
}

31
lib/store.ts Normal file
View File

@@ -0,0 +1,31 @@
import { create } from "zustand";
export interface ActiveJob {
id: string;
type: string;
status: string;
progress: number;
total: number;
}
interface AppStore {
activeJobs: ActiveJob[];
sidebarCollapsed: boolean;
addJob: (job: ActiveJob) => void;
updateJob: (id: string, updates: Partial<ActiveJob>) => void;
removeJob: (id: string) => void;
setSidebarCollapsed: (v: boolean) => void;
}
export const useAppStore = create<AppStore>((set) => ({
activeJobs: [],
sidebarCollapsed: false,
addJob: (job) => set((s) => ({ activeJobs: [...s.activeJobs, job] })),
updateJob: (id, updates) =>
set((s) => ({
activeJobs: s.activeJobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
})),
removeJob: (id) =>
set((s) => ({ activeJobs: s.activeJobs.filter((j) => j.id !== id) })),
setSidebarCollapsed: (v) => set({ sidebarCollapsed: v }),
}));

53
lib/utils/csv.ts Normal file
View File

@@ -0,0 +1,53 @@
import Papa from "papaparse";
import * as XLSX from "xlsx";
export function parseCSV(content: string): { data: Record<string, string>[]; headers: string[] } {
const result = Papa.parse<Record<string, string>>(content, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim(),
});
return {
data: result.data,
headers: result.meta.fields || [],
};
}
export function detectDomainColumn(headers: string[]): string | null {
const candidates = ["domain", "website", "url", "web", "site", "homepage", "company_domain", "company_url"];
for (const candidate of candidates) {
const found = headers.find(h => h.toLowerCase().includes(candidate));
if (found) return found;
}
return null;
}
export interface ExportRow {
company_name?: string;
domain?: string;
contact_name?: string;
contact_title?: string;
email?: string;
confidence_score?: number | string;
source_tab?: string;
job_id?: string;
found_at?: string;
}
export function exportToCSV(rows: ExportRow[], filename: string): void {
const csv = Papa.unparse(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
export function exportToExcel(rows: ExportRow[], filename: string): void {
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Leads");
XLSX.writeFile(wb, filename);
}

34
lib/utils/domains.ts Normal file
View File

@@ -0,0 +1,34 @@
export function cleanDomain(raw: string): string {
if (!raw) return "";
let domain = raw.trim().toLowerCase();
// Remove protocol
domain = domain.replace(/^https?:\/\//i, "");
// Remove www.
domain = domain.replace(/^www\./i, "");
// Remove paths, query strings, fragments
domain = domain.split("/")[0];
domain = domain.split("?")[0];
domain = domain.split("#")[0];
// Remove trailing dots
domain = domain.replace(/\.$/, "");
return domain;
}
export function extractDomainFromUrl(url: string): string {
try {
const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
return parsed.hostname.replace(/^www\./i, "").toLowerCase();
} catch {
return cleanDomain(url);
}
}
const SOCIAL_DIRS = [
"linkedin.com", "facebook.com", "instagram.com", "twitter.com",
"x.com", "yelp.com", "google.com", "maps.google.com", "wikipedia.org",
"xing.com", "youtube.com", "tiktok.com", "pinterest.com",
];
export function isSocialOrDirectory(domain: string): boolean {
return SOCIAL_DIRS.some(d => domain === d || domain.endsWith(`.${d}`));
}

16
lib/utils/encryption.ts Normal file
View File

@@ -0,0 +1,16 @@
import CryptoJS from "crypto-js";
const SECRET = process.env.APP_ENCRYPTION_SECRET || "leadflow-default-secret-key-32ch";
export function encrypt(text: string): string {
return CryptoJS.AES.encrypt(text, SECRET).toString();
}
export function decrypt(ciphertext: string): string {
try {
const bytes = CryptoJS.AES.decrypt(ciphertext, SECRET);
return bytes.toString(CryptoJS.enc.Utf8);
} catch {
return "";
}
}

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

1161
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,21 +10,29 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@libsql/client": "^0.17.0",
"@prisma/adapter-better-sqlite3": "^7.5.0",
"@prisma/adapter-libsql": "^7.5.0",
"@prisma/client": "^7.5.0", "@prisma/client": "^7.5.0",
"@types/better-sqlite3": "^7.6.13",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/xlsx": "^0.0.35", "@types/xlsx": "^0.0.35",
"apify-client": "^2.22.2",
"axios": "^1.13.6", "axios": "^1.13.6",
"better-sqlite3": "^12.8.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.7", "next": "16.1.7",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"prisma": "^7.5.0", "prisma": "^7.5.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "ApiCredential" (
"id" TEXT NOT NULL PRIMARY KEY,
"service" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Job" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"config" TEXT NOT NULL DEFAULT '{}',
"totalLeads" INTEGER NOT NULL DEFAULT 0,
"emailsFound" INTEGER NOT NULL DEFAULT 0,
"error" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "LeadResult" (
"id" TEXT NOT NULL PRIMARY KEY,
"jobId" TEXT NOT NULL,
"companyName" TEXT,
"domain" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"confidence" REAL,
"linkedinUrl" TEXT,
"source" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadResult_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiCredential_service_key" ON "ApiCredential"("service");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

43
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,43 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model ApiCredential {
id String @id @default(cuid())
service String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Job {
id String @id @default(cuid())
type String
status String @default("pending")
config String @default("{}")
totalLeads Int @default(0)
emailsFound Int @default(0)
error String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
results LeadResult[]
}
model LeadResult {
id String @id @default(cuid())
jobId String
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
companyName String?
domain String?
contactName String?
contactTitle String?
email String?
confidence Float?
linkedinUrl String?
source String?
createdAt DateTime @default(now())
}