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:
38
.dockerignore
Normal file
38
.dockerignore
Normal 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
2
.env.local.example
Normal file
@@ -0,0 +1,2 @@
|
||||
APP_ENCRYPTION_SECRET=your-32-character-secret-here!!
|
||||
DATABASE_URL=file:./leadflow.db
|
||||
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal 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
17
.gitignore
vendored
@@ -30,8 +30,13 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# env files — NEVER commit secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Keep the example template
|
||||
!.env.local.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -39,3 +44,11 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
/data/
|
||||
|
||||
73
Dockerfile
Normal file
73
Dockerfile
Normal 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
135
README.md
@@ -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
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### Apify
|
||||
- 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
364
app/airscale/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FileDropZone } from "@/components/shared/FileDropZone";
|
||||
import { RoleChipsInput } from "@/components/shared/RoleChipsInput";
|
||||
import { ProgressCard } from "@/components/shared/ProgressCard";
|
||||
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
|
||||
import { ExportButtons } from "@/components/shared/ExportButtons";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { parseCSV, detectDomainColumn, type ExportRow } from "@/lib/utils/csv";
|
||||
import { cleanDomain } from "@/lib/utils/domains";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, ChevronRight, AlertCircle } from "lucide-react";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const DEFAULT_ROLES: DecisionMakerCategory[] = ["ceo"];
|
||||
|
||||
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
|
||||
{ value: "ceo", label: "CEO / Owner / Founder" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "finance", label: "Finance" },
|
||||
{ value: "hr", label: "HR" },
|
||||
{ value: "it", label: "IT" },
|
||||
{ value: "logistics", label: "Logistics" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "buyer", label: "Procurement" },
|
||||
{ value: "sales", label: "Sales" },
|
||||
];
|
||||
|
||||
interface CompanyRow {
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export default function AirScalePage() {
|
||||
const [csvData, setCsvData] = useState<Record<string, string>[]>([]);
|
||||
const [headers, setHeaders] = useState<string[]>([]);
|
||||
const [domainCol, setDomainCol] = useState<string>("");
|
||||
const [nameCol, setNameCol] = useState<string>("");
|
||||
const [categories, setCategories] = useState<DecisionMakerCategory[]>(DEFAULT_ROLES);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobStatus, setJobStatus] = useState<string>("idle");
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const { addJob, updateJob, removeJob } = useAppStore();
|
||||
|
||||
const onFile = useCallback((content: string) => {
|
||||
const { data, headers: h } = parseCSV(content);
|
||||
setCsvData(data);
|
||||
setHeaders(h);
|
||||
const detected = detectDomainColumn(h);
|
||||
if (detected) setDomainCol(detected);
|
||||
const nameGuess = h.find(x => /company|name|firma/i.test(x));
|
||||
if (nameGuess) setNameCol(nameGuess);
|
||||
}, []);
|
||||
|
||||
const companies: CompanyRow[] = csvData
|
||||
.map(row => ({
|
||||
name: nameCol ? (row[nameCol] || "") : "",
|
||||
domain: cleanDomain(row[domainCol] || ""),
|
||||
}))
|
||||
.filter(c => c.domain);
|
||||
|
||||
const withDomain = companies.length;
|
||||
const withoutDomain = csvData.length - withDomain;
|
||||
|
||||
const startEnrichment = async () => {
|
||||
if (!companies.length) return toast.error("No companies with domains found");
|
||||
if (!categories.length) return toast.error("Select at least one decision maker category");
|
||||
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setJobStatus("running");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs/airscale-enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companies, categories }),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed to start job");
|
||||
|
||||
setJobId(data.jobId);
|
||||
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
|
||||
toast.success("Enrichment started!");
|
||||
pollJob(data.jobId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to start");
|
||||
setRunning(false);
|
||||
setJobStatus("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const pollJob = (id: string) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${id}/status`);
|
||||
const data = await res.json() as {
|
||||
status: string; totalLeads: number; emailsFound: number;
|
||||
results: ResultRow[]; error?: string;
|
||||
};
|
||||
|
||||
setProgress({ current: data.emailsFound, total: data.totalLeads });
|
||||
setResults(data.results || []);
|
||||
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
|
||||
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
clearInterval(interval);
|
||||
setJobStatus(data.status);
|
||||
setRunning(false);
|
||||
removeJob(id);
|
||||
if (data.status === "complete") {
|
||||
toast.success(`Done! Found ${data.emailsFound} emails from ${data.totalLeads} companies`);
|
||||
} else {
|
||||
toast.error(`Job failed: ${data.error || "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(interval);
|
||||
setRunning(false);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const exportRows: ExportRow[] = results.map(r => ({
|
||||
company_name: r.companyName,
|
||||
domain: r.domain,
|
||||
contact_name: r.contactName,
|
||||
contact_title: r.contactTitle,
|
||||
email: r.email,
|
||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
||||
source_tab: "airscale",
|
||||
job_id: jobId || "",
|
||||
found_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const hitRate = results.length > 0
|
||||
? Math.round((results.filter(r => r.email).length / results.length) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-blue-600/10 to-purple-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-400 mb-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>Tab 1</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span>AirScale Companies</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">AirScale → Email Enrichment</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Upload an AirScale CSV export and find decision maker emails via Anymailfinder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
|
||||
Upload AirScale CSV
|
||||
</h2>
|
||||
<FileDropZone onFile={onFile} label="Drop your AirScale CSV export here" />
|
||||
|
||||
{csvData.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{ label: "Total rows", value: csvData.length, color: "text-white" },
|
||||
{ label: "With domains", value: withDomain, color: "text-green-400" },
|
||||
{ label: "Missing domains", value: withoutDomain, color: "text-yellow-400" },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="bg-[#0d0d18] rounded-lg px-4 py-2.5 border border-[#1e1e2e]">
|
||||
<p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p>
|
||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Column mapper */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Domain Column</Label>
|
||||
<Select value={domainCol} onValueChange={v => setDomainCol(v ?? "")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue placeholder="Select domain column..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
{headers.map(h => (
|
||||
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Company Name Column (optional)</Label>
|
||||
<Select value={nameCol} onValueChange={v => setNameCol(v ?? "")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue placeholder="Select name column..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
<SelectItem value="" className="text-gray-400">None</SelectItem>
|
||||
{headers.map(h => (
|
||||
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="rounded-lg border border-[#1e1e2e] overflow-hidden">
|
||||
<div className="bg-[#0d0d18] px-4 py-2 text-xs text-gray-500 border-b border-[#1e1e2e]">
|
||||
Preview (first 5 rows)
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e1e2e]">
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left text-gray-400">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvData.slice(0, 5).map((row, i) => (
|
||||
<tr key={i} className="border-b border-[#1e1e2e]/50">
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-gray-300 truncate max-w-[150px]">
|
||||
{row[h] || "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
|
||||
Decision Maker Categories
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-300 text-sm">Select categories to search (in order of priority)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setCategories(prev =>
|
||||
prev.includes(opt.value)
|
||||
? prev.filter(c => c !== opt.value)
|
||||
: [...prev, opt.value]
|
||||
);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
categories.includes(opt.value)
|
||||
? "bg-blue-500/20 text-blue-300 border-blue-500/40"
|
||||
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-blue-500/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Categories are searched in priority order. First category with a valid result wins.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Step 3: Run */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
|
||||
Run Enrichment
|
||||
</h2>
|
||||
|
||||
{!running && jobStatus === "idle" && (
|
||||
csvData.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="Upload a CSV to get started"
|
||||
description="Upload your AirScale export above, then configure and run enrichment."
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
onClick={startEnrichment}
|
||||
disabled={!withDomain || !domainCol || !categories.length}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
|
||||
>
|
||||
Start Enrichment ({withDomain} companies)
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{(running || jobStatus === "running") && (
|
||||
<ProgressCard
|
||||
title="Enriching companies..."
|
||||
current={progress.current}
|
||||
total={progress.total || withDomain}
|
||||
subtitle="Finding decision maker emails via Anymailfinder"
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{jobStatus === "complete" && (
|
||||
<ProgressCard
|
||||
title="Enrichment complete"
|
||||
current={progress.current}
|
||||
total={progress.total}
|
||||
subtitle={`Hit rate: ${hitRate}%`}
|
||||
status="complete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{jobStatus === "failed" && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Enrichment failed. Check your API key in Settings.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">4</span>
|
||||
Results
|
||||
</h2>
|
||||
<ExportButtons
|
||||
rows={exportRows}
|
||||
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||
summary={`${results.filter(r => r.email).length} emails found • ${hitRate}% hit rate`}
|
||||
/>
|
||||
</div>
|
||||
<ResultsTable rows={results} loading={running && results.length === 0} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/api/credentials/route.ts
Normal file
51
app/api/credentials/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt, decrypt } from "@/lib/utils/encryption";
|
||||
|
||||
const SERVICES = ["anymailfinder", "apify", "vayne", "airscale"] as const;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const creds = await prisma.apiCredential.findMany();
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const svc of SERVICES) {
|
||||
result[svc] = creds.some(c => c.service === svc && c.value);
|
||||
}
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
console.error("GET /api/credentials error:", err);
|
||||
return NextResponse.json({ anymailfinder: false, apify: false, vayne: false, airscale: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as Record<string, string>;
|
||||
for (const [service, value] of Object.entries(body)) {
|
||||
if (!SERVICES.includes(service as typeof SERVICES[number])) continue;
|
||||
if (value === null || value === undefined) continue;
|
||||
await prisma.apiCredential.upsert({
|
||||
where: { service },
|
||||
create: { service, value: value ? encrypt(value) : "" },
|
||||
update: { value: value ? encrypt(value) : "" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("POST /api/credentials error:", err);
|
||||
return NextResponse.json({ error: "Failed to save credentials" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET a specific decrypted value (for internal API use only)
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const { service } = await req.json() as { service: string };
|
||||
const cred = await prisma.apiCredential.findUnique({ where: { service } });
|
||||
if (!cred) return NextResponse.json({ value: null });
|
||||
return NextResponse.json({ value: decrypt(cred.value) });
|
||||
} catch (err) {
|
||||
console.error("PUT /api/credentials error:", err);
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
app/api/credentials/test/route.ts
Normal file
50
app/api/credentials/test/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
import axios from "axios";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const service = req.nextUrl.searchParams.get("service");
|
||||
if (!service) return NextResponse.json({ ok: false, error: "Missing service" }, { status: 400 });
|
||||
|
||||
const cred = await prisma.apiCredential.findUnique({ where: { service } });
|
||||
if (!cred?.value) return NextResponse.json({ ok: false, error: "Not configured" });
|
||||
|
||||
const key = decrypt(cred.value);
|
||||
if (!key) return NextResponse.json({ ok: false, error: "Empty key" });
|
||||
|
||||
try {
|
||||
switch (service) {
|
||||
case "anymailfinder": {
|
||||
// Test with a known domain — no credits charged if email not found
|
||||
const res = await axios.post(
|
||||
"https://api.anymailfinder.com/v5.1/find-email/decision-maker",
|
||||
{ domain: "microsoft.com", decision_maker_category: ["ceo"] },
|
||||
{ headers: { Authorization: key }, timeout: 15000 }
|
||||
);
|
||||
return NextResponse.json({ ok: res.status === 200 });
|
||||
}
|
||||
case "apify": {
|
||||
const res = await axios.get("https://api.apify.com/v2/users/me", {
|
||||
params: { token: key },
|
||||
timeout: 10000,
|
||||
});
|
||||
return NextResponse.json({ ok: res.status === 200 });
|
||||
}
|
||||
case "vayne": {
|
||||
const res = await axios.get("https://www.vayne.io/api/credits", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: 10000,
|
||||
});
|
||||
return NextResponse.json({ ok: res.status === 200 });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ ok: false, error: "Unknown service" });
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
// 402 Payment Required = valid key but no credits → still connected
|
||||
if (status === 402) return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: false });
|
||||
}
|
||||
}
|
||||
42
app/api/export/[jobId]/route.ts
Normal file
42
app/api/export/[jobId]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import Papa from "papaparse";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { jobId } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { results: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
|
||||
const rows = job.results.map(r => ({
|
||||
company_name: r.companyName || "",
|
||||
domain: r.domain || "",
|
||||
contact_name: r.contactName || "",
|
||||
contact_title: r.contactTitle || "",
|
||||
email: r.email || "",
|
||||
confidence_score: r.confidence !== null ? Math.round((r.confidence || 0) * 100) + "%" : "",
|
||||
source_tab: job.type,
|
||||
job_id: job.id,
|
||||
found_at: r.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const csv = Papa.unparse(rows);
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.csv"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("GET /api/export/[jobId] error:", err);
|
||||
return NextResponse.json({ error: "Failed to export" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
app/api/jobs/[id]/route.ts
Normal file
16
app/api/jobs/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await prisma.job.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("DELETE /api/jobs/[id] error:", err);
|
||||
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
app/api/jobs/[id]/status/route.ts
Normal file
47
app/api/jobs/[id]/status/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
results: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
config: JSON.parse(job.config),
|
||||
totalLeads: job.totalLeads,
|
||||
emailsFound: job.emailsFound,
|
||||
error: job.error,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
results: job.results.map(r => ({
|
||||
id: r.id,
|
||||
companyName: r.companyName,
|
||||
domain: r.domain,
|
||||
contactName: r.contactName,
|
||||
contactTitle: r.contactTitle,
|
||||
email: r.email,
|
||||
confidence: r.confidence,
|
||||
linkedinUrl: r.linkedinUrl,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("GET /api/jobs/[id]/status error:", err);
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
106
app/api/jobs/airscale-enrich/route.ts
Normal file
106
app/api/jobs/airscale-enrich/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
import { cleanDomain } from "@/lib/utils/domains";
|
||||
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
companies: Array<{ name: string; domain: string }>;
|
||||
categories: DecisionMakerCategory[];
|
||||
};
|
||||
|
||||
const { companies, categories } = body;
|
||||
if (!companies?.length) {
|
||||
return NextResponse.json({ error: "No companies provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||
if (!cred?.value) {
|
||||
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||
}
|
||||
const apiKey = decrypt(cred.value);
|
||||
|
||||
// Build domain → company map
|
||||
const domainMap = new Map<string, string>();
|
||||
for (const c of companies) {
|
||||
const d = cleanDomain(c.domain);
|
||||
if (d) domainMap.set(d, c.name);
|
||||
}
|
||||
const domains = Array.from(domainMap.keys());
|
||||
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
type: "airscale",
|
||||
status: "running",
|
||||
config: JSON.stringify({ categories, totalDomains: domains.length }),
|
||||
totalLeads: domains.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Run enrichment in background
|
||||
runEnrichment(job.id, domains, domainMap, categories, apiKey).catch(console.error);
|
||||
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
console.error("POST /api/jobs/airscale-enrich error:", err);
|
||||
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function runEnrichment(
|
||||
jobId: string,
|
||||
domains: string[],
|
||||
domainMap: Map<string, string>,
|
||||
categories: DecisionMakerCategory[],
|
||||
apiKey: string
|
||||
) {
|
||||
try {
|
||||
// Use bulk API: submit all domains, poll for completion, then store results.
|
||||
const results = await bulkSearchDomains(
|
||||
domains,
|
||||
categories,
|
||||
apiKey,
|
||||
async (processed, total) => {
|
||||
// Update progress while bulk job is running
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { totalLeads: total },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Store all results
|
||||
let emailsFound = 0;
|
||||
for (const result of results) {
|
||||
const hasEmail = !!result.valid_email;
|
||||
if (hasEmail) emailsFound++;
|
||||
|
||||
await prisma.leadResult.create({
|
||||
data: {
|
||||
jobId,
|
||||
companyName: domainMap.get(result.domain || "") || null,
|
||||
domain: result.domain || null,
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
source: JSON.stringify({ email_status: result.email_status, category: result.decision_maker_category }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: results.length },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "failed", error: message },
|
||||
});
|
||||
}
|
||||
}
|
||||
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
import {
|
||||
submitBulkPersonSearch,
|
||||
getBulkSearchStatus,
|
||||
downloadBulkResults,
|
||||
searchDecisionMakerByDomain,
|
||||
type DecisionMakerCategory,
|
||||
} from "@/lib/services/anymailfinder";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
jobId: string;
|
||||
resultIds: string[];
|
||||
categories: DecisionMakerCategory[];
|
||||
};
|
||||
const { jobId, resultIds, categories } = body;
|
||||
|
||||
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||
if (!cred?.value) {
|
||||
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||
}
|
||||
const apiKey = decrypt(cred.value);
|
||||
|
||||
const results = await prisma.leadResult.findMany({
|
||||
where: { id: { in: resultIds }, jobId, domain: { not: null } },
|
||||
});
|
||||
|
||||
const enrichJob = await prisma.job.create({
|
||||
data: {
|
||||
type: "linkedin-enrich",
|
||||
status: "running",
|
||||
config: JSON.stringify({ parentJobId: jobId, categories }),
|
||||
totalLeads: results.length,
|
||||
},
|
||||
});
|
||||
|
||||
runLinkedInEnrich(enrichJob.id, jobId, results, categories, apiKey).catch(console.error);
|
||||
|
||||
return NextResponse.json({ jobId: enrichJob.id });
|
||||
} catch (err) {
|
||||
console.error("POST /api/jobs/linkedin-enrich error:", err);
|
||||
return NextResponse.json({ error: "Failed to start enrichment" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function runLinkedInEnrich(
|
||||
enrichJobId: string,
|
||||
parentJobId: string,
|
||||
results: Array<{
|
||||
id: string; domain: string | null; contactName: string | null;
|
||||
companyName: string | null; contactTitle: string | null; linkedinUrl: string | null;
|
||||
}>,
|
||||
categories: DecisionMakerCategory[],
|
||||
apiKey: string
|
||||
) {
|
||||
let emailsFound = 0;
|
||||
|
||||
try {
|
||||
// Separate results into those with names (person search) and those without (decision maker search)
|
||||
const withNames: typeof results = [];
|
||||
const withoutNames: typeof results = [];
|
||||
|
||||
for (const r of results) {
|
||||
if (r.contactName && r.domain) {
|
||||
withNames.push(r);
|
||||
} else if (r.domain) {
|
||||
withoutNames.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Map to look up results by domain
|
||||
const resultByDomain = new Map(results.map(r => [r.domain!, r]));
|
||||
|
||||
// 1. Bulk person name search for leads with names
|
||||
if (withNames.length > 0) {
|
||||
const leads = withNames.map(r => {
|
||||
const nameParts = (r.contactName || "").trim().split(/\s+/);
|
||||
return {
|
||||
domain: r.domain!,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(1).join(" ") || "",
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const searchId = await submitBulkPersonSearch(leads, apiKey, `linkedin-enrich-${enrichJobId}`);
|
||||
|
||||
// Poll for completion
|
||||
let status;
|
||||
do {
|
||||
await sleep(5000);
|
||||
status = await getBulkSearchStatus(searchId, apiKey);
|
||||
} while (status.status !== "completed" && status.status !== "failed");
|
||||
|
||||
if (status.status === "completed") {
|
||||
const rows = await downloadBulkResults(searchId, apiKey);
|
||||
|
||||
for (const row of rows) {
|
||||
const domain = row["domain"] || row["Domain"] || "";
|
||||
const result = resultByDomain.get(domain);
|
||||
if (!result) continue;
|
||||
|
||||
const email = row["email"] || row["Email"] || null;
|
||||
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
|
||||
const isValid = emailStatus === "valid";
|
||||
if (isValid) emailsFound++;
|
||||
|
||||
await prisma.leadResult.update({
|
||||
where: { id: result.id },
|
||||
data: {
|
||||
email: email || null,
|
||||
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
|
||||
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
|
||||
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Bulk person search error:", err);
|
||||
// Fall through — will attempt decision-maker search below
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Decision-maker search for leads without names
|
||||
for (const r of withoutNames) {
|
||||
if (!r.domain) continue;
|
||||
try {
|
||||
const found = await searchDecisionMakerByDomain(r.domain, categories, apiKey);
|
||||
const isValid = !!found.valid_email;
|
||||
if (isValid) emailsFound++;
|
||||
|
||||
await prisma.leadResult.update({
|
||||
where: { id: r.id },
|
||||
data: {
|
||||
email: found.email || null,
|
||||
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
|
||||
contactName: found.person_full_name || r.contactName || null,
|
||||
contactTitle: found.person_job_title || r.contactTitle || null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.job.update({ where: { id: enrichJobId }, data: { emailsFound } });
|
||||
} catch (err) {
|
||||
console.error(`Decision-maker search error for domain ${r.domain}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: enrichJobId },
|
||||
data: { status: "complete", emailsFound },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.job.update({
|
||||
where: { id: enrichJobId },
|
||||
data: { status: "failed", error: message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
41
app/api/jobs/route.ts
Normal file
41
app/api/jobs/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const jobs = await prisma.job.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
const totalLeads = jobs.reduce((s, j) => s + j.totalLeads, 0);
|
||||
const totalEmails = jobs.reduce((s, j) => s + j.emailsFound, 0);
|
||||
const completedJobs = jobs.filter(j => j.status === "complete" && j.totalLeads > 0);
|
||||
const avgHitRate = completedJobs.length > 0
|
||||
? Math.round(
|
||||
completedJobs.reduce((s, j) => s + (j.emailsFound / j.totalLeads) * 100, 0) / completedJobs.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map(j => ({
|
||||
id: j.id,
|
||||
type: j.type,
|
||||
status: j.status,
|
||||
totalLeads: j.totalLeads,
|
||||
emailsFound: j.emailsFound,
|
||||
createdAt: j.createdAt,
|
||||
error: j.error,
|
||||
})),
|
||||
stats: {
|
||||
totalJobs: jobs.length,
|
||||
totalLeads,
|
||||
totalEmails,
|
||||
avgHitRate,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("GET /api/jobs error:", err);
|
||||
return NextResponse.json({ jobs: [], stats: {} }, { status: 500 });
|
||||
}
|
||||
}
|
||||
155
app/api/jobs/serp-enrich/route.ts
Normal file
155
app/api/jobs/serp-enrich/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
import { isSocialOrDirectory } from "@/lib/utils/domains";
|
||||
import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify";
|
||||
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
query: string;
|
||||
maxPages: number;
|
||||
countryCode: string;
|
||||
languageCode: string;
|
||||
filterSocial: boolean;
|
||||
categories: DecisionMakerCategory[];
|
||||
selectedDomains?: string[];
|
||||
};
|
||||
|
||||
const apifyCred = await prisma.apiCredential.findUnique({ where: { service: "apify" } });
|
||||
const anymailCred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||
|
||||
if (!apifyCred?.value) return NextResponse.json({ error: "Apify API token not configured" }, { status: 400 });
|
||||
if (!anymailCred?.value) return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||
|
||||
const apifyToken = decrypt(apifyCred.value);
|
||||
const anymailKey = decrypt(anymailCred.value);
|
||||
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
type: "serp",
|
||||
status: "running",
|
||||
config: JSON.stringify(body),
|
||||
totalLeads: 0,
|
||||
},
|
||||
});
|
||||
|
||||
runSerpEnrich(job.id, body, apifyToken, anymailKey).catch(console.error);
|
||||
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
console.error("POST /api/jobs/serp-enrich error:", err);
|
||||
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function runSerpEnrich(
|
||||
jobId: string,
|
||||
params: {
|
||||
query: string; maxPages: number; countryCode: string; languageCode: string;
|
||||
filterSocial: boolean; categories: DecisionMakerCategory[]; selectedDomains?: string[];
|
||||
},
|
||||
apifyToken: string,
|
||||
anymailKey: string
|
||||
) {
|
||||
try {
|
||||
// 1. Run Apify SERP scraper
|
||||
const runId = await runGoogleSerpScraper(
|
||||
params.query, params.maxPages, params.countryCode, params.languageCode, apifyToken
|
||||
);
|
||||
|
||||
// 2. Poll until complete
|
||||
let runStatus = "";
|
||||
let datasetId = "";
|
||||
while (runStatus !== "SUCCEEDED" && runStatus !== "FAILED" && runStatus !== "ABORTED") {
|
||||
await sleep(3000);
|
||||
const result = await pollRunStatus(runId, apifyToken);
|
||||
runStatus = result.status;
|
||||
datasetId = result.defaultDatasetId;
|
||||
}
|
||||
|
||||
if (runStatus !== "SUCCEEDED") throw new Error(`Apify run ${runStatus}`);
|
||||
|
||||
// 3. Fetch results
|
||||
let serpResults = await fetchDatasetItems(datasetId, apifyToken);
|
||||
|
||||
// 4. Filter social/directories
|
||||
if (params.filterSocial) {
|
||||
serpResults = serpResults.filter(r => !isSocialOrDirectory(r.domain));
|
||||
}
|
||||
|
||||
// 5. Deduplicate domains
|
||||
const seenDomains = new Set<string>();
|
||||
const uniqueResults = serpResults.filter(r => {
|
||||
if (!r.domain || seenDomains.has(r.domain)) return false;
|
||||
seenDomains.add(r.domain);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 6. Apply selectedDomains filter if provided
|
||||
const filteredResults = params.selectedDomains?.length
|
||||
? uniqueResults.filter(r => params.selectedDomains!.includes(r.domain))
|
||||
: uniqueResults;
|
||||
|
||||
const domains = filteredResults.map(r => r.domain);
|
||||
const serpMap = new Map(filteredResults.map(r => [r.domain, r]));
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { totalLeads: domains.length },
|
||||
});
|
||||
|
||||
// 7. Enrich with Anymailfinder Bulk API
|
||||
const enrichResults = await bulkSearchDomains(
|
||||
domains,
|
||||
params.categories,
|
||||
anymailKey,
|
||||
async (_completed, total) => {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||
}
|
||||
);
|
||||
|
||||
// 8. Store results
|
||||
let emailsFound = 0;
|
||||
for (const result of enrichResults) {
|
||||
const serpData = serpMap.get(result.domain || "");
|
||||
const hasEmail = !!result.valid_email;
|
||||
if (hasEmail) emailsFound++;
|
||||
|
||||
await prisma.leadResult.create({
|
||||
data: {
|
||||
jobId,
|
||||
companyName: serpData?.title || null,
|
||||
domain: result.domain || null,
|
||||
contactName: result.person_full_name || null,
|
||||
contactTitle: result.person_job_title || null,
|
||||
email: result.email || null,
|
||||
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
||||
linkedinUrl: result.person_linkedin_url || null,
|
||||
source: JSON.stringify({
|
||||
url: serpData?.url,
|
||||
description: serpData?.description,
|
||||
position: serpData?.position,
|
||||
email_status: result.email_status,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "failed", error: message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
116
app/api/jobs/vayne-scrape/route.ts
Normal file
116
app/api/jobs/vayne-scrape/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
import { createOrder, getOrderStatus, triggerExport, downloadOrderCSV } from "@/lib/services/vayne";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { salesNavUrl: string; maxResults: number };
|
||||
const { salesNavUrl, maxResults } = body;
|
||||
|
||||
if (!salesNavUrl?.includes("linkedin.com/sales")) {
|
||||
return NextResponse.json({ error: "Invalid Sales Navigator URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cred = await prisma.apiCredential.findUnique({ where: { service: "vayne" } });
|
||||
if (!cred?.value) {
|
||||
return NextResponse.json({ error: "Vayne API token not configured" }, { status: 400 });
|
||||
}
|
||||
const apiToken = decrypt(cred.value);
|
||||
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
type: "linkedin",
|
||||
status: "running",
|
||||
config: JSON.stringify({ salesNavUrl, maxResults }),
|
||||
totalLeads: 0,
|
||||
},
|
||||
});
|
||||
|
||||
runVayneScrape(job.id, salesNavUrl, maxResults, apiToken).catch(console.error);
|
||||
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
console.error("POST /api/jobs/vayne-scrape error:", err);
|
||||
return NextResponse.json({ error: "Failed to start scrape" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function runVayneScrape(
|
||||
jobId: string,
|
||||
salesNavUrl: string,
|
||||
maxResults: number,
|
||||
apiToken: string
|
||||
) {
|
||||
try {
|
||||
// 1. Create Vayne order
|
||||
const order = await createOrder(salesNavUrl, maxResults, apiToken, `LeadFlow-${jobId.slice(0, 8)}`);
|
||||
const orderId = order.id;
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { config: JSON.stringify({ salesNavUrl, maxResults, vayneOrderId: orderId }) },
|
||||
});
|
||||
|
||||
// 2. Poll until finished
|
||||
let status = order.scraping_status;
|
||||
let scraped = 0;
|
||||
while (status !== "finished" && status !== "failed") {
|
||||
await sleep(5000);
|
||||
const updated = await getOrderStatus(orderId, apiToken);
|
||||
status = updated.scraping_status;
|
||||
scraped = updated.scraped || 0;
|
||||
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: scraped } });
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
throw new Error("Vayne scraping failed");
|
||||
}
|
||||
|
||||
// 3. Trigger export
|
||||
let exportOrder = await triggerExport(orderId, apiToken);
|
||||
|
||||
// 4. Poll for export completion
|
||||
let exportStatus = exportOrder.exports?.[0]?.status;
|
||||
while (exportStatus !== "completed") {
|
||||
await sleep(3000);
|
||||
exportOrder = await getOrderStatus(orderId, apiToken);
|
||||
exportStatus = exportOrder.exports?.[0]?.status;
|
||||
if (exportStatus === undefined) break; // fallback
|
||||
}
|
||||
|
||||
const fileUrl = exportOrder.exports?.[0]?.file_url;
|
||||
if (!fileUrl) throw new Error("No export file URL returned by Vayne");
|
||||
|
||||
// 5. Download and parse CSV
|
||||
const profiles = await downloadOrderCSV(fileUrl);
|
||||
|
||||
// 6. Store results
|
||||
await prisma.leadResult.createMany({
|
||||
data: profiles.map(p => ({
|
||||
jobId,
|
||||
companyName: p.company || null,
|
||||
domain: p.companyDomain || null,
|
||||
contactName: p.fullName || null,
|
||||
contactTitle: p.title || null,
|
||||
linkedinUrl: p.linkedinUrl || null,
|
||||
source: JSON.stringify({ location: p.location }),
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "complete", totalLeads: profiles.length },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "failed", error: message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
141
app/globals.css
141
app/globals.css
@@ -1,129 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: #0a0a0f;
|
||||
--card: #111118;
|
||||
--border: #1e1e2e;
|
||||
--primary: #3b82f6;
|
||||
--secondary: #8b5cf6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--foreground: #f0f0f5;
|
||||
--muted: #6b7280;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0f; }
|
||||
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { TopBar } from "@/components/layout/TopBar";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "LeadFlow — Lead Generation Platform",
|
||||
description: "Unified lead generation and email enrichment platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" theme="dark" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
419
app/linkedin/page.tsx
Normal file
419
app/linkedin/page.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ProgressCard } from "@/components/shared/ProgressCard";
|
||||
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
|
||||
import { ExportButtons } from "@/components/shared/ExportButtons";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Linkedin, ChevronRight, AlertTriangle, CheckCircle2, XCircle,
|
||||
ChevronDown, ChevronUp, Info
|
||||
} from "lucide-react";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||
import type { ExportRow } from "@/lib/utils/csv";
|
||||
|
||||
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
|
||||
{ value: "ceo", label: "CEO / Owner / Founder" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "sales", label: "Sales" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "finance", label: "Finance" },
|
||||
{ value: "hr", label: "HR" },
|
||||
{ value: "it", label: "IT" },
|
||||
{ value: "buyer", label: "Procurement" },
|
||||
{ value: "logistics", label: "Logistics" },
|
||||
];
|
||||
|
||||
type Stage = "idle" | "scraping" | "scraped" | "enriching" | "done" | "failed";
|
||||
|
||||
export default function LinkedInPage() {
|
||||
const [salesNavUrl, setSalesNavUrl] = useState("");
|
||||
const [maxResults, setMaxResults] = useState(100);
|
||||
const [vayneConfigured, setVayneConfigured] = useState<boolean | null>(null);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [stage, setStage] = useState<Stage>("idle");
|
||||
const [scrapeJobId, setScrapeJobId] = useState<string | null>(null);
|
||||
const [enrichJobId, setEnrichJobId] = useState<string | null>(null);
|
||||
const [scrapeProgress, setScrapeProgress] = useState({ current: 0, total: 0 });
|
||||
const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 });
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [categories, setCategories] = useState<DecisionMakerCategory[]>(["ceo"]);
|
||||
const { addJob, updateJob, removeJob } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/credentials")
|
||||
.then(r => r.json())
|
||||
.then((d: Record<string, boolean>) => setVayneConfigured(d.vayne))
|
||||
.catch(() => setVayneConfigured(false));
|
||||
}, []);
|
||||
|
||||
const urlValid = salesNavUrl.includes("linkedin.com/sales");
|
||||
|
||||
const startScrape = async () => {
|
||||
if (!urlValid) return toast.error("Please paste a valid Sales Navigator URL");
|
||||
if (!vayneConfigured) return toast.error("Configure your Vayne API token in Settings first");
|
||||
|
||||
setStage("scraping");
|
||||
setScrapeProgress({ current: 0, total: maxResults });
|
||||
setResults([]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs/vayne-scrape", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ salesNavUrl, maxResults }),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
|
||||
|
||||
setScrapeJobId(data.jobId);
|
||||
addJob({ id: data.jobId, type: "linkedin-scrape", status: "running", progress: 0, total: maxResults });
|
||||
pollScrape(data.jobId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to start scrape");
|
||||
setStage("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const pollScrape = (id: string) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${id}/status`);
|
||||
const data = await res.json() as {
|
||||
status: string; totalLeads: number; results: ResultRow[];
|
||||
};
|
||||
|
||||
setScrapeProgress({ current: data.totalLeads, total: maxResults });
|
||||
if (data.results?.length) setResults(data.results);
|
||||
|
||||
updateJob(id, { status: data.status, progress: data.totalLeads });
|
||||
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
clearInterval(interval);
|
||||
removeJob(id);
|
||||
if (data.status === "complete") {
|
||||
setStage("scraped");
|
||||
setResults(data.results || []);
|
||||
setSelectedIds(data.results?.map(r => r.id) || []);
|
||||
toast.success(`Scraped ${data.totalLeads} profiles from LinkedIn`);
|
||||
} else {
|
||||
setStage("failed");
|
||||
toast.error("Scrape failed. Check Vayne token in Settings.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(interval);
|
||||
setStage("failed");
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const startEnrich = async () => {
|
||||
if (!selectedIds.length) return toast.error("Select at least one profile to enrich");
|
||||
if (!categories.length) return toast.error("Select at least one category");
|
||||
|
||||
setStage("enriching");
|
||||
setEnrichProgress({ current: 0, total: selectedIds.length });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs/linkedin-enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId: scrapeJobId, resultIds: selectedIds, categories }),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
|
||||
|
||||
setEnrichJobId(data.jobId);
|
||||
addJob({ id: data.jobId, type: "linkedin-enrich", status: "running", progress: 0, total: selectedIds.length });
|
||||
pollEnrich(data.jobId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to start enrichment");
|
||||
setStage("scraped");
|
||||
}
|
||||
};
|
||||
|
||||
const pollEnrich = (id: string) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${id}/status`);
|
||||
const data = await res.json() as {
|
||||
status: string; totalLeads: number; emailsFound: number;
|
||||
};
|
||||
|
||||
setEnrichProgress({ current: data.emailsFound, total: data.totalLeads });
|
||||
updateJob(id, { status: data.status, progress: data.emailsFound });
|
||||
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
clearInterval(interval);
|
||||
removeJob(id);
|
||||
// Refresh results from scrape job
|
||||
const jobRes = await fetch(`/api/jobs/${scrapeJobId}/status`);
|
||||
const jobData = await jobRes.json() as { results: ResultRow[] };
|
||||
setResults(jobData.results || []);
|
||||
|
||||
if (data.status === "complete") {
|
||||
setStage("done");
|
||||
toast.success(`Found ${data.emailsFound} emails`);
|
||||
} else {
|
||||
setStage("scraped");
|
||||
toast.error("Enrichment failed");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const exportRows: ExportRow[] = results.map(r => ({
|
||||
company_name: r.companyName,
|
||||
domain: r.domain,
|
||||
contact_name: r.contactName,
|
||||
contact_title: r.contactTitle,
|
||||
email: r.email,
|
||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
||||
source_tab: "linkedin",
|
||||
job_id: scrapeJobId || "",
|
||||
found_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const emailsFound = results.filter(r => r.email).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-blue-700/10 to-blue-500/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/5 to-transparent" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-400 mb-2">
|
||||
<Linkedin className="w-4 h-4" />
|
||||
<span>Tab 2</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span>LinkedIn Sales Navigator</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">LinkedIn → Email Pipeline</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Scrape Sales Navigator profiles via Vayne, then enrich with Anymailfinder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Guide */}
|
||||
<Card className="bg-[#111118] border-purple-500/20 p-0 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setGuideOpen(g => !g)}
|
||||
className="w-full flex items-center justify-between px-6 py-4 hover:bg-purple-500/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-purple-300 font-medium">
|
||||
<Info className="w-4 h-4" />
|
||||
Recommended Sales Navigator Filter Settings
|
||||
</div>
|
||||
{guideOpen ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
|
||||
</button>
|
||||
{guideOpen && (
|
||||
<div className="px-6 pb-5 border-t border-purple-500/10 space-y-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<div>
|
||||
<p className="text-gray-400 font-medium mb-2">Keywords</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["Solarlösungen", "Founder", "Co-Founder", "CEO", "Geschäftsführer"].map(k => (
|
||||
<span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 font-medium mb-2">Headcount</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["1–10", "11–50", "51–200"].map(k => (
|
||||
<span key={k} className="bg-blue-500/10 text-blue-300 px-2 py-0.5 rounded text-xs">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 font-medium mb-2">Country</p>
|
||||
<span className="bg-green-500/10 text-green-300 px-2 py-0.5 rounded text-xs">Germany (Deutschland)</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 font-medium mb-2">Target Titles</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["Founder", "Co-Founder", "CEO", "CTO", "COO", "Owner", "President", "Principal", "Partner"].map(k => (
|
||||
<span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Step 1: Input */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
|
||||
Sales Navigator URL
|
||||
</h2>
|
||||
|
||||
{/* Vayne status */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border text-sm ${
|
||||
vayneConfigured === null ? "bg-[#0d0d18] border-[#1e1e2e] text-gray-400"
|
||||
: vayneConfigured ? "bg-green-500/5 border-green-500/20 text-green-400"
|
||||
: "bg-red-500/5 border-red-500/20 text-red-400"
|
||||
}`}>
|
||||
{vayneConfigured === null ? "Checking Vayne configuration..."
|
||||
: vayneConfigured ? (
|
||||
<><CheckCircle2 className="w-4 h-4" /> Vayne API token configured</>
|
||||
) : (
|
||||
<><XCircle className="w-4 h-4" /> Vayne token not configured — <a href="/settings" className="underline">go to Settings</a></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Sales Navigator Search URL</Label>
|
||||
<Textarea
|
||||
placeholder="https://www.linkedin.com/sales/search/people?query=..."
|
||||
value={salesNavUrl}
|
||||
onChange={e => setSalesNavUrl(e.target.value)}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20 font-mono text-xs"
|
||||
/>
|
||||
{salesNavUrl && !urlValid && (
|
||||
<p className="text-xs text-red-400 mt-1">Must be a linkedin.com/sales/search URL</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Max results to scrape</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={2500}
|
||||
value={maxResults}
|
||||
onChange={e => setMaxResults(Number(e.target.value))}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2 bg-yellow-500/5 border border-yellow-500/20 rounded-lg px-4 py-3">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-yellow-300">
|
||||
Do not close this tab while scraping is in progress. The job runs in the background
|
||||
but progress tracking requires this tab to remain open.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={startScrape}
|
||||
disabled={!urlValid || !vayneConfigured || stage === "scraping"}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
Start LinkedIn Scrape
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Scrape progress */}
|
||||
{(stage === "scraping") && (
|
||||
<ProgressCard
|
||||
title="Scraping LinkedIn profiles via Vayne..."
|
||||
current={scrapeProgress.current}
|
||||
total={scrapeProgress.total}
|
||||
subtitle="Creating order → Scraping → Generating export..."
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
{results.length > 0 && (
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
|
||||
Scraped Profiles ({results.length})
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Select profiles to include in email enrichment</p>
|
||||
<ResultsTable
|
||||
rows={results}
|
||||
selectable
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Enrich */}
|
||||
{(stage === "scraped" || stage === "enriching" || stage === "done") && results.length > 0 && (
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
|
||||
Enrich with Emails
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setCategories(prev =>
|
||||
prev.includes(opt.value) ? prev.filter(c => c !== opt.value) : [...prev, opt.value]
|
||||
)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
categories.includes(opt.value)
|
||||
? "bg-blue-500/20 text-blue-300 border-blue-500/40"
|
||||
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-blue-500/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stage === "enriching" && (
|
||||
<ProgressCard
|
||||
title="Enriching profiles..."
|
||||
current={enrichProgress.current}
|
||||
total={enrichProgress.total}
|
||||
subtitle="Finding emails via Anymailfinder"
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{stage === "scraped" && (
|
||||
<Button
|
||||
onClick={startEnrich}
|
||||
disabled={!selectedIds.length || !categories.length}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
|
||||
>
|
||||
Enrich {selectedIds.length} Selected Profiles with Emails
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
{(stage === "done" || emailsFound > 0) && (
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6">
|
||||
<ExportButtons
|
||||
rows={exportRows}
|
||||
filename={`linkedin-leads-${scrapeJobId?.slice(0, 8) || "export"}`}
|
||||
summary={`${emailsFound} emails found from ${results.length} profiles`}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{stage === "idle" && (
|
||||
<EmptyState
|
||||
icon={Linkedin}
|
||||
title="Start by pasting a Sales Navigator URL"
|
||||
description="Configure your search filters in Sales Navigator, copy the URL, and paste it above to begin scraping."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
app/page.tsx
64
app/page.tsx
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/airscale");
|
||||
}
|
||||
|
||||
213
app/results/page.tsx
Normal file
213
app/results/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { StatusBadge } from "@/components/shared/ProgressCard";
|
||||
import { toast } from "sonner";
|
||||
import { BarChart3, Building2, Linkedin, Search, Download, Trash2, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
totalLeads: number;
|
||||
emailsFound: number;
|
||||
createdAt: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalLeads: number;
|
||||
totalEmails: number;
|
||||
avgHitRate: number;
|
||||
totalJobs: number;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: typeof Building2; label: string; color: string }> = {
|
||||
airscale: { icon: Building2, label: "AirScale", color: "text-blue-400" },
|
||||
linkedin: { icon: Linkedin, label: "LinkedIn", color: "text-blue-500" },
|
||||
"linkedin-enrich": { icon: Linkedin, label: "LinkedIn Enrich", color: "text-blue-400" },
|
||||
serp: { icon: Search, label: "SERP", color: "text-purple-400" },
|
||||
};
|
||||
|
||||
export default function ResultsPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/jobs");
|
||||
const data = await res.json() as { jobs: Job[]; stats: Stats };
|
||||
setJobs(data.jobs || []);
|
||||
setStats(data.stats || { totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
|
||||
} catch {
|
||||
toast.error("Failed to load job history");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteJob = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
|
||||
setJobs(prev => prev.filter(j => j.id !== id));
|
||||
toast.success("Job deleted");
|
||||
} catch {
|
||||
toast.error("Failed to delete job");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadJob = (id: string) => {
|
||||
window.open(`/api/export/${id}`, "_blank");
|
||||
};
|
||||
|
||||
const hitRate = (job: Job) =>
|
||||
job.totalLeads > 0 ? Math.round((job.emailsFound / job.totalLeads) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-green-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative">
|
||||
<h1 className="text-2xl font-bold text-white">Results & History</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">All past enrichment jobs and their results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total Jobs", value: stats.totalJobs, color: "text-white" },
|
||||
{ label: "Total Leads", value: stats.totalLeads.toLocaleString(), color: "text-blue-400" },
|
||||
{ label: "Emails Found", value: stats.totalEmails.toLocaleString(), color: "text-green-400" },
|
||||
{ label: "Avg Hit Rate", value: `${stats.avgHitRate}%`, color: "text-purple-400" },
|
||||
].map(stat => (
|
||||
<Card key={stat.label} className="bg-[#111118] border-[#1e1e2e] p-5">
|
||||
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Jobs table */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1e1e2e]">
|
||||
<h2 className="font-semibold text-white">Job History</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadJobs}
|
||||
className="border-[#2e2e3e] text-gray-300 hover:bg-[#1a1a28]"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full bg-[#1e1e2e]" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No jobs yet"
|
||||
description="Run an enrichment job from any of the pipeline tabs to see results here."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Job ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Started</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Emails</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Hit Rate</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job, idx) => {
|
||||
const cfg = TYPE_CONFIG[job.type] || { icon: BarChart3, label: job.type, color: "text-gray-400" };
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<tr
|
||||
key={job.id}
|
||||
className={cn(
|
||||
"border-b border-[#1e1e2e]/50",
|
||||
idx % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f1a]"
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className={`flex items-center gap-2 ${cfg.color}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">{cfg.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{job.id.slice(0, 12)}...</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{new Date(job.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={job.status === "complete" ? "complete" : job.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white font-medium">{job.totalLeads.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-green-400 font-medium">{job.emailsFound.toLocaleString()}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
hitRate(job) >= 50 ? "text-green-400" : hitRate(job) >= 20 ? "text-yellow-400" : "text-red-400"
|
||||
)}>
|
||||
{hitRate(job)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => downloadJob(job.id)}
|
||||
disabled={job.status !== "complete"}
|
||||
className="h-7 px-2 text-gray-400 hover:text-white hover:bg-[#1a1a28]"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteJob(job.id)}
|
||||
className="h-7 px-2 text-gray-400 hover:text-red-400 hover:bg-red-500/5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
app/serp/page.tsx
Normal file
329
app/serp/page.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProgressCard } from "@/components/shared/ProgressCard";
|
||||
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
|
||||
import { ExportButtons } from "@/components/shared/ExportButtons";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { toast } from "sonner";
|
||||
import { Search, ChevronRight } from "lucide-react";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||
import type { ExportRow } from "@/lib/utils/csv";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const RESULT_OPTIONS = [10, 25, 50, 100, 200];
|
||||
|
||||
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
|
||||
{ value: "ceo", label: "CEO / Owner / Founder" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "sales", label: "Sales" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "finance", label: "Finance" },
|
||||
{ value: "hr", label: "HR" },
|
||||
{ value: "it", label: "IT" },
|
||||
{ value: "buyer", label: "Procurement" },
|
||||
{ value: "logistics", label: "Logistics" },
|
||||
];
|
||||
|
||||
type Stage = "idle" | "running" | "done" | "failed";
|
||||
|
||||
export default function SerpPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [numResults, setNumResults] = useState(50);
|
||||
const [country, setCountry] = useState("de");
|
||||
const [language, setLanguage] = useState("de");
|
||||
const [filterSocial, setFilterSocial] = useState(true);
|
||||
const [categories, setCategories] = useState<DecisionMakerCategory[]>(["ceo"]);
|
||||
const [stage, setStage] = useState<Stage>("idle");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const { addJob, updateJob, removeJob } = useAppStore();
|
||||
|
||||
// maxPages: since Google limits to 10 results/page, divide by 10
|
||||
const maxPages = Math.max(1, Math.ceil(numResults / 10));
|
||||
|
||||
const startJob = async () => {
|
||||
if (!query.trim()) return toast.error("Enter a search query");
|
||||
if (!categories.length) return toast.error("Select at least one category");
|
||||
|
||||
setStage("running");
|
||||
setResults([]);
|
||||
setProgress({ current: 0, total: numResults, phase: "Searching Google..." });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs/serp-enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
maxPages,
|
||||
countryCode: country,
|
||||
languageCode: language,
|
||||
filterSocial,
|
||||
categories,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
|
||||
|
||||
setJobId(data.jobId);
|
||||
addJob({ id: data.jobId, type: "serp", status: "running", progress: 0, total: numResults });
|
||||
pollJob(data.jobId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to start");
|
||||
setStage("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const pollJob = (id: string) => {
|
||||
let phase = "Searching Google...";
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${id}/status`);
|
||||
const data = await res.json() as {
|
||||
status: string; totalLeads: number; emailsFound: number;
|
||||
results: ResultRow[];
|
||||
};
|
||||
|
||||
// Infer phase from progress
|
||||
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Enriching domains with Anymailfinder...";
|
||||
if (data.emailsFound > 0) phase = `Found ${data.emailsFound} emails so far...`;
|
||||
|
||||
setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, phase });
|
||||
if (data.results?.length) setResults(data.results);
|
||||
|
||||
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
|
||||
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
clearInterval(interval);
|
||||
removeJob(id);
|
||||
setResults(data.results || []);
|
||||
if (data.status === "complete") {
|
||||
setStage("done");
|
||||
toast.success(`Done! Found ${data.emailsFound} emails`);
|
||||
} else {
|
||||
setStage("failed");
|
||||
toast.error("Job failed. Check your API keys in Settings.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(interval);
|
||||
setStage("failed");
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const exportRows: ExportRow[] = results.map(r => ({
|
||||
company_name: r.companyName,
|
||||
domain: r.domain,
|
||||
contact_name: r.contactName,
|
||||
contact_title: r.contactTitle,
|
||||
email: r.email,
|
||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
||||
source_tab: "serp",
|
||||
job_id: jobId || "",
|
||||
found_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const emailsFound = results.filter(r => r.email).length;
|
||||
const hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>Tab 3</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span>Google SERP</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">SERP → Email Enrichment</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Scrape Google search results via Apify, extract domains, then find decision maker emails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Configure */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span>
|
||||
Search Configuration
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Search Term</Label>
|
||||
<Input
|
||||
placeholder='e.g. "Solaranlage Installateur Deutschland"'
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Number of Results</Label>
|
||||
<Select value={String(numResults)} onValueChange={v => setNumResults(Number(v))}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
{RESULT_OPTIONS.map(n => (
|
||||
<SelectItem key={n} value={String(n)} className="text-gray-300">{n} results</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500 mt-1">~{maxPages} page{maxPages > 1 ? "s" : ""} of Google</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Country</Label>
|
||||
<Select value={country} onValueChange={v => setCountry(v ?? "de")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
{[
|
||||
{ value: "de", label: "Germany (DE)" },
|
||||
{ value: "at", label: "Austria (AT)" },
|
||||
{ value: "ch", label: "Switzerland (CH)" },
|
||||
{ value: "us", label: "United States (US)" },
|
||||
{ value: "gb", label: "United Kingdom (GB)" },
|
||||
].map(c => (
|
||||
<SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300 text-sm mb-1.5 block">Language</Label>
|
||||
<Select value={language} onValueChange={v => setLanguage(v ?? "de")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
{[
|
||||
{ value: "de", label: "German" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
].map(l => (
|
||||
<SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="filterSocial"
|
||||
checked={filterSocial}
|
||||
onCheckedChange={v => setFilterSocial(!!v)}
|
||||
className="border-[#2e2e3e] mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer">
|
||||
Exclude social media, directories & aggregators
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Filters out: LinkedIn, Facebook, Instagram, Yelp, Wikipedia, Xing, Twitter/X, YouTube, Google Maps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Step 2: Categories */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span>
|
||||
Decision Maker Categories
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setCategories(prev =>
|
||||
prev.includes(opt.value) ? prev.filter(c => c !== opt.value) : [...prev, opt.value]
|
||||
)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
categories.includes(opt.value)
|
||||
? "bg-purple-500/20 text-purple-300 border-purple-500/40"
|
||||
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-purple-500/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Run */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span>
|
||||
Run Pipeline
|
||||
</h2>
|
||||
|
||||
{stage === "idle" || stage === "failed" ? (
|
||||
<Button
|
||||
onClick={startJob}
|
||||
disabled={!query.trim() || !categories.length}
|
||||
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-purple-500/25 transition-all"
|
||||
>
|
||||
Start SERP Search
|
||||
</Button>
|
||||
) : stage === "running" ? (
|
||||
<ProgressCard
|
||||
title="Running SERP pipeline..."
|
||||
current={progress.current}
|
||||
total={progress.total}
|
||||
subtitle={progress.phase}
|
||||
status="running"
|
||||
/>
|
||||
) : (
|
||||
<ProgressCard
|
||||
title="Pipeline complete"
|
||||
current={emailsFound}
|
||||
total={results.length}
|
||||
subtitle={`Hit rate: ${hitRate}% · ${results.length} domains scraped`}
|
||||
status="complete"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-base font-semibold text-white">Results</h2>
|
||||
<ExportButtons
|
||||
rows={exportRows}
|
||||
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||
summary={`${emailsFound} emails found • ${hitRate}% hit rate`}
|
||||
/>
|
||||
</div>
|
||||
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{stage === "idle" && (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="Configure and run a SERP search"
|
||||
description="Enter a search term, select your target categories, and hit Start to find leads from Google search results."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
app/settings/page.tsx
Normal file
244
app/settings/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { CheckCircle2, XCircle, Loader2, ExternalLink, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Credential {
|
||||
service: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
link: string;
|
||||
linkLabel: string;
|
||||
isTextarea?: boolean;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
const CREDENTIALS: Credential[] = [
|
||||
{
|
||||
service: "anymailfinder",
|
||||
label: "Anymailfinder API Key",
|
||||
placeholder: "amf_...",
|
||||
link: "https://anymailfinder.com/account",
|
||||
linkLabel: "Get API key",
|
||||
},
|
||||
{
|
||||
service: "apify",
|
||||
label: "Apify API Token",
|
||||
placeholder: "apify_api_...",
|
||||
link: "https://console.apify.com/account/integrations",
|
||||
linkLabel: "Get API token",
|
||||
},
|
||||
{
|
||||
service: "vayne",
|
||||
label: "Vayne API Token",
|
||||
placeholder: "Bearer token from vayne.io dashboard",
|
||||
link: "https://www.vayne.io",
|
||||
linkLabel: "Vayne Dashboard",
|
||||
isTextarea: true,
|
||||
instructions: "Generate your API token in the API Settings section of your Vayne Dashboard at vayne.io. Use Bearer authentication — Vayne manages the LinkedIn session on their end.",
|
||||
},
|
||||
];
|
||||
|
||||
interface CredentialState {
|
||||
value: string;
|
||||
saved: boolean;
|
||||
testing: boolean;
|
||||
testResult: "ok" | "fail" | null;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [creds, setCreds] = useState<Record<string, CredentialState>>(() =>
|
||||
Object.fromEntries(
|
||||
CREDENTIALS.map(c => [c.service, { value: "", saved: false, testing: false, testResult: null, show: false }])
|
||||
)
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Load existing (masked) status
|
||||
useEffect(() => {
|
||||
fetch("/api/credentials")
|
||||
.then(r => r.json())
|
||||
.then((data: Record<string, boolean>) => {
|
||||
setCreds(prev => {
|
||||
const next = { ...prev };
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (next[k]) {
|
||||
next[k] = { ...next[k], saved: v };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const update = (service: string, field: keyof CredentialState, val: unknown) => {
|
||||
setCreds(prev => ({ ...prev, [service]: { ...prev[service], [field]: val } }));
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
for (const [service, state] of Object.entries(creds)) {
|
||||
if (state.value) body[service] = state.value;
|
||||
}
|
||||
const res = await fetch("/api/credentials", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error("Save failed");
|
||||
toast.success("Credentials saved successfully");
|
||||
setCreds(prev => {
|
||||
const next = { ...prev };
|
||||
for (const service of Object.keys(body)) {
|
||||
next[service] = { ...next[service], saved: true };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to save credentials");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (service: string) => {
|
||||
update(service, "testing", true);
|
||||
update(service, "testResult", null);
|
||||
try {
|
||||
const res = await fetch(`/api/credentials/test?service=${service}`);
|
||||
const data = await res.json() as { ok: boolean };
|
||||
update(service, "testResult", data.ok ? "ok" : "fail");
|
||||
if (data.ok) toast.success(`${service} connection verified`);
|
||||
else toast.error(`${service} connection failed — check your key`);
|
||||
} catch {
|
||||
update(service, "testResult", "fail");
|
||||
toast.error("Connection test failed");
|
||||
} finally {
|
||||
update(service, "testing", false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
API credentials are encrypted with AES-256 and stored locally in SQLite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credential cards */}
|
||||
{CREDENTIALS.map(cred => {
|
||||
const state = creds[cred.service];
|
||||
return (
|
||||
<Card key={cred.service} className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<Label className="text-white font-semibold text-base">{cred.label}</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{state.saved ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Configured
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400">
|
||||
<XCircle className="w-3.5 h-3.5" /> Not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={cred.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{cred.linkLabel} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{cred.instructions && (
|
||||
<p className="text-xs text-gray-500 bg-[#0d0d18] rounded-lg p-3 border border-[#1e1e2e]">
|
||||
{cred.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{cred.isTextarea ? (
|
||||
<Textarea
|
||||
placeholder={cred.placeholder}
|
||||
value={state.value}
|
||||
onChange={e => update(cred.service, "value", e.target.value)}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={state.show ? "text" : "password"}
|
||||
placeholder={state.saved && !state.value ? "••••••••••••••••" : cred.placeholder}
|
||||
value={state.value}
|
||||
onChange={e => update(cred.service, "value", e.target.value)}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update(cred.service, "show", !state.show)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{state.show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={state.testing || (!state.saved && !state.value)}
|
||||
onClick={() => testConnection(cred.service)}
|
||||
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
|
||||
>
|
||||
{state.testing ? (
|
||||
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</>
|
||||
) : "Test Connection"}
|
||||
</Button>
|
||||
{state.testResult === "ok" && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{state.testResult === "fail" && (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400">
|
||||
<XCircle className="w-3.5 h-3.5" /> Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={saveAll}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
|
||||
>
|
||||
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save All Credentials"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
components/layout/Sidebar.tsx
Normal file
101
components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/layout/TopBar.tsx
Normal file
36
components/layout/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
components/shared/EmptyState.tsx
Normal file
21
components/shared/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/shared/ExportButtons.tsx
Normal file
38
components/shared/ExportButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
components/shared/FileDropZone.tsx
Normal file
68
components/shared/FileDropZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
components/shared/ProgressCard.tsx
Normal file
64
components/shared/ProgressCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
components/shared/ResultsTable.tsx
Normal file
145
components/shared/ResultsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
components/shared/RoleChipsInput.tsx
Normal file
61
components/shared/RoleChipsInput.tsx
Normal 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
76
components/ui/alert.tsx
Normal 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
103
components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
29
components/ui/checkbox.tsx
Normal file
29
components/ui/checkbox.tsx
Normal 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
157
components/ui/dialog.tsx
Normal 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
20
components/ui/input.tsx
Normal 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
20
components/ui/label.tsx
Normal 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
201
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
49
components/ui/sonner.tsx
Normal 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 }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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
26
docker-compose.yml
Normal 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
11
docker-entrypoint.sh
Normal 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
17
lib/db.ts
Normal 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;
|
||||
232
lib/services/anymailfinder.ts
Normal file
232
lib/services/anymailfinder.ts
Normal 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
84
lib/services/apify.ts
Normal 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
153
lib/services/vayne.ts
Normal 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
31
lib/store.ts
Normal 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
53
lib/utils/csv.ts
Normal 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
34
lib/utils/domains.ts
Normal 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
16
lib/utils/encryption.ts
Normal 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 "";
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1161
package-lock.json
generated
1161
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,21 +10,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"apify-client": "^2.22.2",
|
||||
"axios": "^1.13.6",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^4.0.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "^0.18.5",
|
||||
|
||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
40
prisma/migrations/20260317100747_init/migration.sql
Normal file
40
prisma/migrations/20260317100747_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
43
prisma/schema.prisma
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user