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*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files — NEVER commit secrets
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Keep the example template
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,3 +44,11 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/lib/generated/prisma
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
/data/
|
||||||
|
|||||||
73
Dockerfile
Normal file
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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
---
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## API Keys — Where to Get Them
|
||||||
|
|
||||||
## Learn More
|
Go to **Settings** in the sidebar to enter and save credentials. All keys are AES-256 encrypted before storage.
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
### Anymailfinder
|
||||||
|
- Sign up at [anymailfinder.com](https://anymailfinder.com)
|
||||||
|
- Account → API → copy your key (format: starts with your account prefix)
|
||||||
|
- Pricing: 2 credits/valid decision maker email, 1 credit/person email
|
||||||
|
- Bulk API charges only when downloading results
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
### Apify
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- Sign up at [apify.com](https://apify.com)
|
||||||
|
- Console → Account → Integrations → API tokens
|
||||||
|
- The app uses the `apify/google-search-scraper` actor (pay-per-event)
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
### Vayne
|
||||||
|
- Sign up at [vayne.io](https://vayne.io)
|
||||||
|
- Dashboard → API Settings → generate token
|
||||||
|
- **Connect LinkedIn** in the Vayne dashboard — Vayne manages the LinkedIn session on their end
|
||||||
|
- No need to manually export cookies
|
||||||
|
|
||||||
## Deploy on Vercel
|
---
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## Pipeline Workflows
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
### Tab 1 — AirScale → Email
|
||||||
|
1. Export companies from AirScale as CSV
|
||||||
|
2. Upload → map domain column
|
||||||
|
3. Select decision maker categories
|
||||||
|
4. Start Enrichment → bulk API runs asynchronously
|
||||||
|
5. Export CSV/Excel
|
||||||
|
|
||||||
|
### Tab 2 — LinkedIn Sales Navigator → Email
|
||||||
|
1. Build search in Sales Navigator, copy URL
|
||||||
|
2. Paste URL + set max results → Start Scrape
|
||||||
|
3. Vayne scrapes profiles (polls until done)
|
||||||
|
4. Select profiles → Enrich with Emails
|
||||||
|
5. Export results
|
||||||
|
|
||||||
|
### Tab 3 — SERP → Email
|
||||||
|
1. Enter search term (e.g. `"Solaranlage Installateur Deutschland"`)
|
||||||
|
2. Set country/language, enable social filter
|
||||||
|
3. Select decision maker categories
|
||||||
|
4. Start → Apify scrapes Google, domains extracted, then bulk enriched
|
||||||
|
5. Export results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Anymailfinder Bulk Works
|
||||||
|
|
||||||
|
1. All domains submitted as one POST to `/v5.1/bulk/json`
|
||||||
|
2. Poll status every 5s until `completed`
|
||||||
|
3. Download results once (credits charged at download, not submission)
|
||||||
|
4. Speed: ~1,000 domains per 5 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLite at `./leadflow.db`. Inspect with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "API key not configured" | Add key in Settings |
|
||||||
|
| Job stuck at "running" | Check server console (`npm run dev` terminal) |
|
||||||
|
| Prisma errors on build | Run `npx prisma generate && npm run build` |
|
||||||
|
|||||||
364
app/airscale/page.tsx
Normal file
364
app/airscale/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { FileDropZone } from "@/components/shared/FileDropZone";
|
||||||
|
import { RoleChipsInput } from "@/components/shared/RoleChipsInput";
|
||||||
|
import { ProgressCard } from "@/components/shared/ProgressCard";
|
||||||
|
import { ResultsTable, type ResultRow } from "@/components/shared/ResultsTable";
|
||||||
|
import { ExportButtons } from "@/components/shared/ExportButtons";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { parseCSV, detectDomainColumn, type ExportRow } from "@/lib/utils/csv";
|
||||||
|
import { cleanDomain } from "@/lib/utils/domains";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Building2, ChevronRight, AlertCircle } from "lucide-react";
|
||||||
|
import { useAppStore } from "@/lib/store";
|
||||||
|
import type { DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
const DEFAULT_ROLES: DecisionMakerCategory[] = ["ceo"];
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS: { value: DecisionMakerCategory; label: string }[] = [
|
||||||
|
{ value: "ceo", label: "CEO / Owner / Founder" },
|
||||||
|
{ value: "engineering", label: "Engineering" },
|
||||||
|
{ value: "finance", label: "Finance" },
|
||||||
|
{ value: "hr", label: "HR" },
|
||||||
|
{ value: "it", label: "IT" },
|
||||||
|
{ value: "logistics", label: "Logistics" },
|
||||||
|
{ value: "marketing", label: "Marketing" },
|
||||||
|
{ value: "operations", label: "Operations" },
|
||||||
|
{ value: "buyer", label: "Procurement" },
|
||||||
|
{ value: "sales", label: "Sales" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CompanyRow {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirScalePage() {
|
||||||
|
const [csvData, setCsvData] = useState<Record<string, string>[]>([]);
|
||||||
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
|
const [domainCol, setDomainCol] = useState<string>("");
|
||||||
|
const [nameCol, setNameCol] = useState<string>("");
|
||||||
|
const [categories, setCategories] = useState<DecisionMakerCategory[]>(DEFAULT_ROLES);
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [jobStatus, setJobStatus] = useState<string>("idle");
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
|
const [results, setResults] = useState<ResultRow[]>([]);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const { addJob, updateJob, removeJob } = useAppStore();
|
||||||
|
|
||||||
|
const onFile = useCallback((content: string) => {
|
||||||
|
const { data, headers: h } = parseCSV(content);
|
||||||
|
setCsvData(data);
|
||||||
|
setHeaders(h);
|
||||||
|
const detected = detectDomainColumn(h);
|
||||||
|
if (detected) setDomainCol(detected);
|
||||||
|
const nameGuess = h.find(x => /company|name|firma/i.test(x));
|
||||||
|
if (nameGuess) setNameCol(nameGuess);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const companies: CompanyRow[] = csvData
|
||||||
|
.map(row => ({
|
||||||
|
name: nameCol ? (row[nameCol] || "") : "",
|
||||||
|
domain: cleanDomain(row[domainCol] || ""),
|
||||||
|
}))
|
||||||
|
.filter(c => c.domain);
|
||||||
|
|
||||||
|
const withDomain = companies.length;
|
||||||
|
const withoutDomain = csvData.length - withDomain;
|
||||||
|
|
||||||
|
const startEnrichment = async () => {
|
||||||
|
if (!companies.length) return toast.error("No companies with domains found");
|
||||||
|
if (!categories.length) return toast.error("Select at least one decision maker category");
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setResults([]);
|
||||||
|
setJobStatus("running");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/jobs/airscale-enrich", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ companies, categories }),
|
||||||
|
});
|
||||||
|
const data = await res.json() as { jobId?: string; error?: string };
|
||||||
|
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed to start job");
|
||||||
|
|
||||||
|
setJobId(data.jobId);
|
||||||
|
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
|
||||||
|
toast.success("Enrichment started!");
|
||||||
|
pollJob(data.jobId);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to start");
|
||||||
|
setRunning(false);
|
||||||
|
setJobStatus("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollJob = (id: string) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jobs/${id}/status`);
|
||||||
|
const data = await res.json() as {
|
||||||
|
status: string; totalLeads: number; emailsFound: number;
|
||||||
|
results: ResultRow[]; error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
setProgress({ current: data.emailsFound, total: data.totalLeads });
|
||||||
|
setResults(data.results || []);
|
||||||
|
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
|
||||||
|
|
||||||
|
if (data.status === "complete" || data.status === "failed") {
|
||||||
|
clearInterval(interval);
|
||||||
|
setJobStatus(data.status);
|
||||||
|
setRunning(false);
|
||||||
|
removeJob(id);
|
||||||
|
if (data.status === "complete") {
|
||||||
|
toast.success(`Done! Found ${data.emailsFound} emails from ${data.totalLeads} companies`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Job failed: ${data.error || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(interval);
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportRows: ExportRow[] = results.map(r => ({
|
||||||
|
company_name: r.companyName,
|
||||||
|
domain: r.domain,
|
||||||
|
contact_name: r.contactName,
|
||||||
|
contact_title: r.contactTitle,
|
||||||
|
email: r.email,
|
||||||
|
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
||||||
|
source_tab: "airscale",
|
||||||
|
job_id: jobId || "",
|
||||||
|
found_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hitRate = results.length > 0
|
||||||
|
? Math.round((results.filter(r => r.email).length / results.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-5xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative rounded-2xl bg-gradient-to-r from-blue-600/10 to-purple-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-blue-400 mb-2">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
<span>Tab 1</span>
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
<span>AirScale Companies</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">AirScale → Email Enrichment</h1>
|
||||||
|
<p className="text-gray-400 mt-1 text-sm">
|
||||||
|
Upload an AirScale CSV export and find decision maker emails via Anymailfinder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Upload */}
|
||||||
|
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
|
||||||
|
Upload AirScale CSV
|
||||||
|
</h2>
|
||||||
|
<FileDropZone onFile={onFile} label="Drop your AirScale CSV export here" />
|
||||||
|
|
||||||
|
{csvData.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[
|
||||||
|
{ label: "Total rows", value: csvData.length, color: "text-white" },
|
||||||
|
{ label: "With domains", value: withDomain, color: "text-green-400" },
|
||||||
|
{ label: "Missing domains", value: withoutDomain, color: "text-yellow-400" },
|
||||||
|
].map(stat => (
|
||||||
|
<div key={stat.label} className="bg-[#0d0d18] rounded-lg px-4 py-2.5 border border-[#1e1e2e]">
|
||||||
|
<p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column mapper */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300 text-sm mb-1.5 block">Domain Column</Label>
|
||||||
|
<Select value={domainCol} onValueChange={v => setDomainCol(v ?? "")}>
|
||||||
|
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||||
|
<SelectValue placeholder="Select domain column..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||||
|
{headers.map(h => (
|
||||||
|
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300 text-sm mb-1.5 block">Company Name Column (optional)</Label>
|
||||||
|
<Select value={nameCol} onValueChange={v => setNameCol(v ?? "")}>
|
||||||
|
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||||
|
<SelectValue placeholder="Select name column..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||||
|
<SelectItem value="" className="text-gray-400">None</SelectItem>
|
||||||
|
{headers.map(h => (
|
||||||
|
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-lg border border-[#1e1e2e] overflow-hidden">
|
||||||
|
<div className="bg-[#0d0d18] px-4 py-2 text-xs text-gray-500 border-b border-[#1e1e2e]">
|
||||||
|
Preview (first 5 rows)
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#1e1e2e]">
|
||||||
|
{headers.slice(0, 6).map(h => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left text-gray-400">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{csvData.slice(0, 5).map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-[#1e1e2e]/50">
|
||||||
|
{headers.slice(0, 6).map(h => (
|
||||||
|
<td key={h} className="px-3 py-2 text-gray-300 truncate max-w-[150px]">
|
||||||
|
{row[h] || "—"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 2: Configure */}
|
||||||
|
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
|
||||||
|
Decision Maker Categories
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-gray-300 text-sm">Select categories to search (in order of priority)</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CATEGORY_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setCategories(prev =>
|
||||||
|
prev.includes(opt.value)
|
||||||
|
? prev.filter(c => c !== opt.value)
|
||||||
|
: [...prev, opt.value]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||||
|
categories.includes(opt.value)
|
||||||
|
? "bg-blue-500/20 text-blue-300 border-blue-500/40"
|
||||||
|
: "bg-[#0d0d18] text-gray-400 border-[#2e2e3e] hover:border-blue-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Categories are searched in priority order. First category with a valid result wins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 3: Run */}
|
||||||
|
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
|
||||||
|
Run Enrichment
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!running && jobStatus === "idle" && (
|
||||||
|
csvData.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
|
title="Upload a CSV to get started"
|
||||||
|
description="Upload your AirScale export above, then configure and run enrichment."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={startEnrichment}
|
||||||
|
disabled={!withDomain || !domainCol || !categories.length}
|
||||||
|
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
|
||||||
|
>
|
||||||
|
Start Enrichment ({withDomain} companies)
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(running || jobStatus === "running") && (
|
||||||
|
<ProgressCard
|
||||||
|
title="Enriching companies..."
|
||||||
|
current={progress.current}
|
||||||
|
total={progress.total || withDomain}
|
||||||
|
subtitle="Finding decision maker emails via Anymailfinder"
|
||||||
|
status="running"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobStatus === "complete" && (
|
||||||
|
<ProgressCard
|
||||||
|
title="Enrichment complete"
|
||||||
|
current={progress.current}
|
||||||
|
total={progress.total}
|
||||||
|
subtitle={`Hit rate: ${hitRate}%`}
|
||||||
|
status="complete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobStatus === "failed" && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Enrichment failed. Check your API key in Settings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">4</span>
|
||||||
|
Results
|
||||||
|
</h2>
|
||||||
|
<ExportButtons
|
||||||
|
rows={exportRows}
|
||||||
|
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||||
|
summary={`${results.filter(r => r.email).length} emails found • ${hitRate}% hit rate`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ResultsTable rows={results} loading={running && results.length === 0} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/api/credentials/route.ts
Normal file
51
app/api/credentials/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { encrypt, decrypt } from "@/lib/utils/encryption";
|
||||||
|
|
||||||
|
const SERVICES = ["anymailfinder", "apify", "vayne", "airscale"] as const;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const creds = await prisma.apiCredential.findMany();
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const svc of SERVICES) {
|
||||||
|
result[svc] = creds.some(c => c.service === svc && c.value);
|
||||||
|
}
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GET /api/credentials error:", err);
|
||||||
|
return NextResponse.json({ anymailfinder: false, apify: false, vayne: false, airscale: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as Record<string, string>;
|
||||||
|
for (const [service, value] of Object.entries(body)) {
|
||||||
|
if (!SERVICES.includes(service as typeof SERVICES[number])) continue;
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
await prisma.apiCredential.upsert({
|
||||||
|
where: { service },
|
||||||
|
create: { service, value: value ? encrypt(value) : "" },
|
||||||
|
update: { value: value ? encrypt(value) : "" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/credentials error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to save credentials" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET a specific decrypted value (for internal API use only)
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { service } = await req.json() as { service: string };
|
||||||
|
const cred = await prisma.apiCredential.findUnique({ where: { service } });
|
||||||
|
if (!cred) return NextResponse.json({ value: null });
|
||||||
|
return NextResponse.json({ value: decrypt(cred.value) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PUT /api/credentials error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/credentials/test/route.ts
Normal file
50
app/api/credentials/test/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decrypt } from "@/lib/utils/encryption";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const service = req.nextUrl.searchParams.get("service");
|
||||||
|
if (!service) return NextResponse.json({ ok: false, error: "Missing service" }, { status: 400 });
|
||||||
|
|
||||||
|
const cred = await prisma.apiCredential.findUnique({ where: { service } });
|
||||||
|
if (!cred?.value) return NextResponse.json({ ok: false, error: "Not configured" });
|
||||||
|
|
||||||
|
const key = decrypt(cred.value);
|
||||||
|
if (!key) return NextResponse.json({ ok: false, error: "Empty key" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (service) {
|
||||||
|
case "anymailfinder": {
|
||||||
|
// Test with a known domain — no credits charged if email not found
|
||||||
|
const res = await axios.post(
|
||||||
|
"https://api.anymailfinder.com/v5.1/find-email/decision-maker",
|
||||||
|
{ domain: "microsoft.com", decision_maker_category: ["ceo"] },
|
||||||
|
{ headers: { Authorization: key }, timeout: 15000 }
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: res.status === 200 });
|
||||||
|
}
|
||||||
|
case "apify": {
|
||||||
|
const res = await axios.get("https://api.apify.com/v2/users/me", {
|
||||||
|
params: { token: key },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: res.status === 200 });
|
||||||
|
}
|
||||||
|
case "vayne": {
|
||||||
|
const res = await axios.get("https://www.vayne.io/api/credits", {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: res.status === 200 });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ ok: false, error: "Unknown service" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
// 402 Payment Required = valid key but no credits → still connected
|
||||||
|
if (status === 402) return NextResponse.json({ ok: true });
|
||||||
|
return NextResponse.json({ ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/export/[jobId]/route.ts
Normal file
42
app/api/export/[jobId]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import Papa from "papaparse";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ jobId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { jobId } = await params;
|
||||||
|
const job = await prisma.job.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
include: { results: { orderBy: { createdAt: "asc" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const rows = job.results.map(r => ({
|
||||||
|
company_name: r.companyName || "",
|
||||||
|
domain: r.domain || "",
|
||||||
|
contact_name: r.contactName || "",
|
||||||
|
contact_title: r.contactTitle || "",
|
||||||
|
email: r.email || "",
|
||||||
|
confidence_score: r.confidence !== null ? Math.round((r.confidence || 0) * 100) + "%" : "",
|
||||||
|
source_tab: job.type,
|
||||||
|
job_id: job.id,
|
||||||
|
found_at: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const csv = Papa.unparse(rows);
|
||||||
|
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv",
|
||||||
|
"Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GET /api/export/[jobId] error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to export" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/api/jobs/[id]/route.ts
Normal file
16
app/api/jobs/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.job.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("DELETE /api/jobs/[id] error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/api/jobs/[id]/status/route.ts
Normal file
47
app/api/jobs/[id]/status/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const job = await prisma.job.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
results: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: job.id,
|
||||||
|
type: job.type,
|
||||||
|
status: job.status,
|
||||||
|
config: JSON.parse(job.config),
|
||||||
|
totalLeads: job.totalLeads,
|
||||||
|
emailsFound: job.emailsFound,
|
||||||
|
error: job.error,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
|
results: job.results.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
companyName: r.companyName,
|
||||||
|
domain: r.domain,
|
||||||
|
contactName: r.contactName,
|
||||||
|
contactTitle: r.contactTitle,
|
||||||
|
email: r.email,
|
||||||
|
confidence: r.confidence,
|
||||||
|
linkedinUrl: r.linkedinUrl,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GET /api/jobs/[id]/status error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/api/jobs/airscale-enrich/route.ts
Normal file
106
app/api/jobs/airscale-enrich/route.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decrypt } from "@/lib/utils/encryption";
|
||||||
|
import { cleanDomain } from "@/lib/utils/domains";
|
||||||
|
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as {
|
||||||
|
companies: Array<{ name: string; domain: string }>;
|
||||||
|
categories: DecisionMakerCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { companies, categories } = body;
|
||||||
|
if (!companies?.length) {
|
||||||
|
return NextResponse.json({ error: "No companies provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||||
|
if (!cred?.value) {
|
||||||
|
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const apiKey = decrypt(cred.value);
|
||||||
|
|
||||||
|
// Build domain → company map
|
||||||
|
const domainMap = new Map<string, string>();
|
||||||
|
for (const c of companies) {
|
||||||
|
const d = cleanDomain(c.domain);
|
||||||
|
if (d) domainMap.set(d, c.name);
|
||||||
|
}
|
||||||
|
const domains = Array.from(domainMap.keys());
|
||||||
|
|
||||||
|
const job = await prisma.job.create({
|
||||||
|
data: {
|
||||||
|
type: "airscale",
|
||||||
|
status: "running",
|
||||||
|
config: JSON.stringify({ categories, totalDomains: domains.length }),
|
||||||
|
totalLeads: domains.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run enrichment in background
|
||||||
|
runEnrichment(job.id, domains, domainMap, categories, apiKey).catch(console.error);
|
||||||
|
|
||||||
|
return NextResponse.json({ jobId: job.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/jobs/airscale-enrich error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEnrichment(
|
||||||
|
jobId: string,
|
||||||
|
domains: string[],
|
||||||
|
domainMap: Map<string, string>,
|
||||||
|
categories: DecisionMakerCategory[],
|
||||||
|
apiKey: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Use bulk API: submit all domains, poll for completion, then store results.
|
||||||
|
const results = await bulkSearchDomains(
|
||||||
|
domains,
|
||||||
|
categories,
|
||||||
|
apiKey,
|
||||||
|
async (processed, total) => {
|
||||||
|
// Update progress while bulk job is running
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { totalLeads: total },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store all results
|
||||||
|
let emailsFound = 0;
|
||||||
|
for (const result of results) {
|
||||||
|
const hasEmail = !!result.valid_email;
|
||||||
|
if (hasEmail) emailsFound++;
|
||||||
|
|
||||||
|
await prisma.leadResult.create({
|
||||||
|
data: {
|
||||||
|
jobId,
|
||||||
|
companyName: domainMap.get(result.domain || "") || null,
|
||||||
|
domain: result.domain || null,
|
||||||
|
contactName: result.person_full_name || null,
|
||||||
|
contactTitle: result.person_job_title || null,
|
||||||
|
email: result.email || null,
|
||||||
|
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
||||||
|
linkedinUrl: result.person_linkedin_url || null,
|
||||||
|
source: JSON.stringify({ email_status: result.email_status, category: result.decision_maker_category }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "complete", emailsFound, totalLeads: results.length },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "failed", error: message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decrypt } from "@/lib/utils/encryption";
|
||||||
|
import {
|
||||||
|
submitBulkPersonSearch,
|
||||||
|
getBulkSearchStatus,
|
||||||
|
downloadBulkResults,
|
||||||
|
searchDecisionMakerByDomain,
|
||||||
|
type DecisionMakerCategory,
|
||||||
|
} from "@/lib/services/anymailfinder";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as {
|
||||||
|
jobId: string;
|
||||||
|
resultIds: string[];
|
||||||
|
categories: DecisionMakerCategory[];
|
||||||
|
};
|
||||||
|
const { jobId, resultIds, categories } = body;
|
||||||
|
|
||||||
|
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||||
|
if (!cred?.value) {
|
||||||
|
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const apiKey = decrypt(cred.value);
|
||||||
|
|
||||||
|
const results = await prisma.leadResult.findMany({
|
||||||
|
where: { id: { in: resultIds }, jobId, domain: { not: null } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichJob = await prisma.job.create({
|
||||||
|
data: {
|
||||||
|
type: "linkedin-enrich",
|
||||||
|
status: "running",
|
||||||
|
config: JSON.stringify({ parentJobId: jobId, categories }),
|
||||||
|
totalLeads: results.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runLinkedInEnrich(enrichJob.id, jobId, results, categories, apiKey).catch(console.error);
|
||||||
|
|
||||||
|
return NextResponse.json({ jobId: enrichJob.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/jobs/linkedin-enrich error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to start enrichment" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLinkedInEnrich(
|
||||||
|
enrichJobId: string,
|
||||||
|
parentJobId: string,
|
||||||
|
results: Array<{
|
||||||
|
id: string; domain: string | null; contactName: string | null;
|
||||||
|
companyName: string | null; contactTitle: string | null; linkedinUrl: string | null;
|
||||||
|
}>,
|
||||||
|
categories: DecisionMakerCategory[],
|
||||||
|
apiKey: string
|
||||||
|
) {
|
||||||
|
let emailsFound = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Separate results into those with names (person search) and those without (decision maker search)
|
||||||
|
const withNames: typeof results = [];
|
||||||
|
const withoutNames: typeof results = [];
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.contactName && r.domain) {
|
||||||
|
withNames.push(r);
|
||||||
|
} else if (r.domain) {
|
||||||
|
withoutNames.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to look up results by domain
|
||||||
|
const resultByDomain = new Map(results.map(r => [r.domain!, r]));
|
||||||
|
|
||||||
|
// 1. Bulk person name search for leads with names
|
||||||
|
if (withNames.length > 0) {
|
||||||
|
const leads = withNames.map(r => {
|
||||||
|
const nameParts = (r.contactName || "").trim().split(/\s+/);
|
||||||
|
return {
|
||||||
|
domain: r.domain!,
|
||||||
|
firstName: nameParts[0] || "",
|
||||||
|
lastName: nameParts.slice(1).join(" ") || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchId = await submitBulkPersonSearch(leads, apiKey, `linkedin-enrich-${enrichJobId}`);
|
||||||
|
|
||||||
|
// Poll for completion
|
||||||
|
let status;
|
||||||
|
do {
|
||||||
|
await sleep(5000);
|
||||||
|
status = await getBulkSearchStatus(searchId, apiKey);
|
||||||
|
} while (status.status !== "completed" && status.status !== "failed");
|
||||||
|
|
||||||
|
if (status.status === "completed") {
|
||||||
|
const rows = await downloadBulkResults(searchId, apiKey);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const domain = row["domain"] || row["Domain"] || "";
|
||||||
|
const result = resultByDomain.get(domain);
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
const email = row["email"] || row["Email"] || null;
|
||||||
|
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
|
||||||
|
const isValid = emailStatus === "valid";
|
||||||
|
if (isValid) emailsFound++;
|
||||||
|
|
||||||
|
await prisma.leadResult.update({
|
||||||
|
where: { id: result.id },
|
||||||
|
data: {
|
||||||
|
email: email || null,
|
||||||
|
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
|
||||||
|
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
|
||||||
|
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Bulk person search error:", err);
|
||||||
|
// Fall through — will attempt decision-maker search below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decision-maker search for leads without names
|
||||||
|
for (const r of withoutNames) {
|
||||||
|
if (!r.domain) continue;
|
||||||
|
try {
|
||||||
|
const found = await searchDecisionMakerByDomain(r.domain, categories, apiKey);
|
||||||
|
const isValid = !!found.valid_email;
|
||||||
|
if (isValid) emailsFound++;
|
||||||
|
|
||||||
|
await prisma.leadResult.update({
|
||||||
|
where: { id: r.id },
|
||||||
|
data: {
|
||||||
|
email: found.email || null,
|
||||||
|
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
|
||||||
|
contactName: found.person_full_name || r.contactName || null,
|
||||||
|
contactTitle: found.person_job_title || r.contactTitle || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.job.update({ where: { id: enrichJobId }, data: { emailsFound } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Decision-maker search error for domain ${r.domain}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: enrichJobId },
|
||||||
|
data: { status: "complete", emailsFound },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: enrichJobId },
|
||||||
|
data: { status: "failed", error: message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
41
app/api/jobs/route.ts
Normal file
41
app/api/jobs/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const jobs = await prisma.job.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLeads = jobs.reduce((s, j) => s + j.totalLeads, 0);
|
||||||
|
const totalEmails = jobs.reduce((s, j) => s + j.emailsFound, 0);
|
||||||
|
const completedJobs = jobs.filter(j => j.status === "complete" && j.totalLeads > 0);
|
||||||
|
const avgHitRate = completedJobs.length > 0
|
||||||
|
? Math.round(
|
||||||
|
completedJobs.reduce((s, j) => s + (j.emailsFound / j.totalLeads) * 100, 0) / completedJobs.length
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
jobs: jobs.map(j => ({
|
||||||
|
id: j.id,
|
||||||
|
type: j.type,
|
||||||
|
status: j.status,
|
||||||
|
totalLeads: j.totalLeads,
|
||||||
|
emailsFound: j.emailsFound,
|
||||||
|
createdAt: j.createdAt,
|
||||||
|
error: j.error,
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
totalJobs: jobs.length,
|
||||||
|
totalLeads,
|
||||||
|
totalEmails,
|
||||||
|
avgHitRate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GET /api/jobs error:", err);
|
||||||
|
return NextResponse.json({ jobs: [], stats: {} }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/api/jobs/serp-enrich/route.ts
Normal file
155
app/api/jobs/serp-enrich/route.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decrypt } from "@/lib/utils/encryption";
|
||||||
|
import { isSocialOrDirectory } from "@/lib/utils/domains";
|
||||||
|
import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify";
|
||||||
|
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as {
|
||||||
|
query: string;
|
||||||
|
maxPages: number;
|
||||||
|
countryCode: string;
|
||||||
|
languageCode: string;
|
||||||
|
filterSocial: boolean;
|
||||||
|
categories: DecisionMakerCategory[];
|
||||||
|
selectedDomains?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const apifyCred = await prisma.apiCredential.findUnique({ where: { service: "apify" } });
|
||||||
|
const anymailCred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||||
|
|
||||||
|
if (!apifyCred?.value) return NextResponse.json({ error: "Apify API token not configured" }, { status: 400 });
|
||||||
|
if (!anymailCred?.value) return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||||
|
|
||||||
|
const apifyToken = decrypt(apifyCred.value);
|
||||||
|
const anymailKey = decrypt(anymailCred.value);
|
||||||
|
|
||||||
|
const job = await prisma.job.create({
|
||||||
|
data: {
|
||||||
|
type: "serp",
|
||||||
|
status: "running",
|
||||||
|
config: JSON.stringify(body),
|
||||||
|
totalLeads: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runSerpEnrich(job.id, body, apifyToken, anymailKey).catch(console.error);
|
||||||
|
|
||||||
|
return NextResponse.json({ jobId: job.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/jobs/serp-enrich error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSerpEnrich(
|
||||||
|
jobId: string,
|
||||||
|
params: {
|
||||||
|
query: string; maxPages: number; countryCode: string; languageCode: string;
|
||||||
|
filterSocial: boolean; categories: DecisionMakerCategory[]; selectedDomains?: string[];
|
||||||
|
},
|
||||||
|
apifyToken: string,
|
||||||
|
anymailKey: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 1. Run Apify SERP scraper
|
||||||
|
const runId = await runGoogleSerpScraper(
|
||||||
|
params.query, params.maxPages, params.countryCode, params.languageCode, apifyToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Poll until complete
|
||||||
|
let runStatus = "";
|
||||||
|
let datasetId = "";
|
||||||
|
while (runStatus !== "SUCCEEDED" && runStatus !== "FAILED" && runStatus !== "ABORTED") {
|
||||||
|
await sleep(3000);
|
||||||
|
const result = await pollRunStatus(runId, apifyToken);
|
||||||
|
runStatus = result.status;
|
||||||
|
datasetId = result.defaultDatasetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runStatus !== "SUCCEEDED") throw new Error(`Apify run ${runStatus}`);
|
||||||
|
|
||||||
|
// 3. Fetch results
|
||||||
|
let serpResults = await fetchDatasetItems(datasetId, apifyToken);
|
||||||
|
|
||||||
|
// 4. Filter social/directories
|
||||||
|
if (params.filterSocial) {
|
||||||
|
serpResults = serpResults.filter(r => !isSocialOrDirectory(r.domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Deduplicate domains
|
||||||
|
const seenDomains = new Set<string>();
|
||||||
|
const uniqueResults = serpResults.filter(r => {
|
||||||
|
if (!r.domain || seenDomains.has(r.domain)) return false;
|
||||||
|
seenDomains.add(r.domain);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Apply selectedDomains filter if provided
|
||||||
|
const filteredResults = params.selectedDomains?.length
|
||||||
|
? uniqueResults.filter(r => params.selectedDomains!.includes(r.domain))
|
||||||
|
: uniqueResults;
|
||||||
|
|
||||||
|
const domains = filteredResults.map(r => r.domain);
|
||||||
|
const serpMap = new Map(filteredResults.map(r => [r.domain, r]));
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { totalLeads: domains.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Enrich with Anymailfinder Bulk API
|
||||||
|
const enrichResults = await bulkSearchDomains(
|
||||||
|
domains,
|
||||||
|
params.categories,
|
||||||
|
anymailKey,
|
||||||
|
async (_completed, total) => {
|
||||||
|
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Store results
|
||||||
|
let emailsFound = 0;
|
||||||
|
for (const result of enrichResults) {
|
||||||
|
const serpData = serpMap.get(result.domain || "");
|
||||||
|
const hasEmail = !!result.valid_email;
|
||||||
|
if (hasEmail) emailsFound++;
|
||||||
|
|
||||||
|
await prisma.leadResult.create({
|
||||||
|
data: {
|
||||||
|
jobId,
|
||||||
|
companyName: serpData?.title || null,
|
||||||
|
domain: result.domain || null,
|
||||||
|
contactName: result.person_full_name || null,
|
||||||
|
contactTitle: result.person_job_title || null,
|
||||||
|
email: result.email || null,
|
||||||
|
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
||||||
|
linkedinUrl: result.person_linkedin_url || null,
|
||||||
|
source: JSON.stringify({
|
||||||
|
url: serpData?.url,
|
||||||
|
description: serpData?.description,
|
||||||
|
position: serpData?.position,
|
||||||
|
email_status: result.email_status,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "failed", error: message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
116
app/api/jobs/vayne-scrape/route.ts
Normal file
116
app/api/jobs/vayne-scrape/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { decrypt } from "@/lib/utils/encryption";
|
||||||
|
import { createOrder, getOrderStatus, triggerExport, downloadOrderCSV } from "@/lib/services/vayne";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as { salesNavUrl: string; maxResults: number };
|
||||||
|
const { salesNavUrl, maxResults } = body;
|
||||||
|
|
||||||
|
if (!salesNavUrl?.includes("linkedin.com/sales")) {
|
||||||
|
return NextResponse.json({ error: "Invalid Sales Navigator URL" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cred = await prisma.apiCredential.findUnique({ where: { service: "vayne" } });
|
||||||
|
if (!cred?.value) {
|
||||||
|
return NextResponse.json({ error: "Vayne API token not configured" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const apiToken = decrypt(cred.value);
|
||||||
|
|
||||||
|
const job = await prisma.job.create({
|
||||||
|
data: {
|
||||||
|
type: "linkedin",
|
||||||
|
status: "running",
|
||||||
|
config: JSON.stringify({ salesNavUrl, maxResults }),
|
||||||
|
totalLeads: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runVayneScrape(job.id, salesNavUrl, maxResults, apiToken).catch(console.error);
|
||||||
|
|
||||||
|
return NextResponse.json({ jobId: job.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/jobs/vayne-scrape error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to start scrape" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runVayneScrape(
|
||||||
|
jobId: string,
|
||||||
|
salesNavUrl: string,
|
||||||
|
maxResults: number,
|
||||||
|
apiToken: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 1. Create Vayne order
|
||||||
|
const order = await createOrder(salesNavUrl, maxResults, apiToken, `LeadFlow-${jobId.slice(0, 8)}`);
|
||||||
|
const orderId = order.id;
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { config: JSON.stringify({ salesNavUrl, maxResults, vayneOrderId: orderId }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Poll until finished
|
||||||
|
let status = order.scraping_status;
|
||||||
|
let scraped = 0;
|
||||||
|
while (status !== "finished" && status !== "failed") {
|
||||||
|
await sleep(5000);
|
||||||
|
const updated = await getOrderStatus(orderId, apiToken);
|
||||||
|
status = updated.scraping_status;
|
||||||
|
scraped = updated.scraped || 0;
|
||||||
|
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: scraped } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "failed") {
|
||||||
|
throw new Error("Vayne scraping failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Trigger export
|
||||||
|
let exportOrder = await triggerExport(orderId, apiToken);
|
||||||
|
|
||||||
|
// 4. Poll for export completion
|
||||||
|
let exportStatus = exportOrder.exports?.[0]?.status;
|
||||||
|
while (exportStatus !== "completed") {
|
||||||
|
await sleep(3000);
|
||||||
|
exportOrder = await getOrderStatus(orderId, apiToken);
|
||||||
|
exportStatus = exportOrder.exports?.[0]?.status;
|
||||||
|
if (exportStatus === undefined) break; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUrl = exportOrder.exports?.[0]?.file_url;
|
||||||
|
if (!fileUrl) throw new Error("No export file URL returned by Vayne");
|
||||||
|
|
||||||
|
// 5. Download and parse CSV
|
||||||
|
const profiles = await downloadOrderCSV(fileUrl);
|
||||||
|
|
||||||
|
// 6. Store results
|
||||||
|
await prisma.leadResult.createMany({
|
||||||
|
data: profiles.map(p => ({
|
||||||
|
jobId,
|
||||||
|
companyName: p.company || null,
|
||||||
|
domain: p.companyDomain || null,
|
||||||
|
contactName: p.fullName || null,
|
||||||
|
contactTitle: p.title || null,
|
||||||
|
linkedinUrl: p.linkedinUrl || null,
|
||||||
|
source: JSON.stringify({ location: p.location }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "complete", totalLeads: profiles.length },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "failed", error: message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
145
app/globals.css
145
app/globals.css
@@ -1,129 +1,30 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
|
||||||
@import "shadcn/tailwind.css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: #0a0a0f;
|
||||||
--foreground: oklch(0.145 0 0);
|
--card: #111118;
|
||||||
--card: oklch(1 0 0);
|
--border: #1e1e2e;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--primary: #3b82f6;
|
||||||
--popover: oklch(1 0 0);
|
--secondary: #8b5cf6;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--success: #22c55e;
|
||||||
--primary: oklch(0.205 0 0);
|
--warning: #f59e0b;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--error: #ef4444;
|
||||||
--secondary: oklch(0.97 0 0);
|
--foreground: #f0f0f5;
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--muted: #6b7280;
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
* {
|
||||||
--background: oklch(0.145 0 0);
|
border-color: var(--border);
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
body {
|
||||||
* {
|
background-color: var(--background);
|
||||||
@apply border-border outline-ring/50;
|
color: var(--foreground);
|
||||||
}
|
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
|
||||||
body {
|
}
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
/* Scrollbar */
|
||||||
html {
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
@apply font-sans;
|
::-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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
|
import { TopBar } from "@/components/layout/TopBar";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "LeadFlow — Lead Generation Platform",
|
||||||
description: "Generated by create next app",
|
description: "Unified lead generation and email enrichment platform",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body className={`${inter.variable} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
|
||||||
>
|
<Sidebar />
|
||||||
{children}
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toaster position="bottom-right" theme="dark" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
419
app/linkedin/page.tsx
Normal file
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() {
|
export default function Home() {
|
||||||
return (
|
redirect("/airscale");
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
213
app/results/page.tsx
Normal file
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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1161
package-lock.json
generated
1161
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,21 +10,29 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.5.0",
|
||||||
|
"@prisma/adapter-libsql": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/xlsx": "^0.0.35",
|
"@types/xlsx": "^0.0.35",
|
||||||
|
"apify-client": "^2.22.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "^7.5.0",
|
"prisma": "^7.5.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^4.0.8",
|
"shadcn": "^4.0.8",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
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