From facf8c9f69157b13b8360b1cd7e499ef52e9fc2d Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Tue, 17 Mar 2026 11:21:11 +0100 Subject: [PATCH] Initial commit: LeadFlow lead generation platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 38 + .env.local.example | 2 + .gitattributes | 9 + .gitignore | 17 +- Dockerfile | 73 ++ README.md | 135 +- app/airscale/page.tsx | 364 ++++++ app/api/credentials/route.ts | 51 + app/api/credentials/test/route.ts | 50 + app/api/export/[jobId]/route.ts | 42 + app/api/jobs/[id]/route.ts | 16 + app/api/jobs/[id]/status/route.ts | 47 + app/api/jobs/airscale-enrich/route.ts | 106 ++ app/api/jobs/linkedin-enrich/route.ts | 167 +++ app/api/jobs/route.ts | 41 + app/api/jobs/serp-enrich/route.ts | 155 +++ app/api/jobs/vayne-scrape/route.ts | 116 ++ app/globals.css | 145 +- app/layout.tsx | 42 +- app/linkedin/page.tsx | 419 ++++++ app/page.tsx | 64 +- app/results/page.tsx | 213 +++ app/serp/page.tsx | 329 +++++ app/settings/page.tsx | 244 ++++ components/layout/Sidebar.tsx | 101 ++ components/layout/TopBar.tsx | 36 + components/shared/EmptyState.tsx | 21 + components/shared/ExportButtons.tsx | 38 + components/shared/FileDropZone.tsx | 68 + components/shared/ProgressCard.tsx | 64 + components/shared/ResultsTable.tsx | 145 ++ components/shared/RoleChipsInput.tsx | 61 + components/ui/alert.tsx | 76 ++ components/ui/card.tsx | 103 ++ components/ui/checkbox.tsx | 29 + components/ui/dialog.tsx | 157 +++ components/ui/input.tsx | 20 + components/ui/label.tsx | 20 + components/ui/select.tsx | 201 +++ components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 49 + components/ui/textarea.tsx | 18 + docker-compose.yml | 26 + docker-entrypoint.sh | 11 + lib/db.ts | 17 + lib/services/anymailfinder.ts | 232 ++++ lib/services/apify.ts | 84 ++ lib/services/vayne.ts | 153 +++ lib/store.ts | 31 + lib/utils/csv.ts | 53 + lib/utils/domains.ts | 34 + lib/utils/encryption.ts | 16 + next.config.ts | 2 +- package-lock.json | 1161 ++++++++++++++++- package.json | 8 + prisma.config.ts | 14 + .../20260317100747_init/migration.sql | 40 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 43 + 59 files changed, 5800 insertions(+), 233 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.local.example create mode 100644 .gitattributes create mode 100644 Dockerfile create mode 100644 app/airscale/page.tsx create mode 100644 app/api/credentials/route.ts create mode 100644 app/api/credentials/test/route.ts create mode 100644 app/api/export/[jobId]/route.ts create mode 100644 app/api/jobs/[id]/route.ts create mode 100644 app/api/jobs/[id]/status/route.ts create mode 100644 app/api/jobs/airscale-enrich/route.ts create mode 100644 app/api/jobs/linkedin-enrich/route.ts create mode 100644 app/api/jobs/route.ts create mode 100644 app/api/jobs/serp-enrich/route.ts create mode 100644 app/api/jobs/vayne-scrape/route.ts create mode 100644 app/linkedin/page.tsx create mode 100644 app/results/page.tsx create mode 100644 app/serp/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 components/layout/Sidebar.tsx create mode 100644 components/layout/TopBar.tsx create mode 100644 components/shared/EmptyState.tsx create mode 100644 components/shared/ExportButtons.tsx create mode 100644 components/shared/FileDropZone.tsx create mode 100644 components/shared/ProgressCard.tsx create mode 100644 components/shared/ResultsTable.tsx create mode 100644 components/shared/RoleChipsInput.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 lib/db.ts create mode 100644 lib/services/anymailfinder.ts create mode 100644 lib/services/apify.ts create mode 100644 lib/services/vayne.ts create mode 100644 lib/store.ts create mode 100644 lib/utils/csv.ts create mode 100644 lib/utils/domains.ts create mode 100644 lib/utils/encryption.ts create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260317100747_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8527c32 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..1fb672f --- /dev/null +++ b/.env.local.example @@ -0,0 +1,2 @@ +APP_ENCRYPTION_SECRET=your-32-character-secret-here!! +DATABASE_URL=file:./leadflow.db diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f5b9ced --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 5ef6a52..66161f3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,13 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# env files — NEVER commit secrets +.env +.env.local +.env.*.local + +# Keep the example template +!.env.local.example # vercel .vercel @@ -39,3 +44,11 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/lib/generated/prisma + +# SQLite database files +*.db +*.db-shm +*.db-wal +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee68da7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e215bc4..c961e37 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,131 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# LeadFlow — Lead Generation & Email Enrichment Platform -## Getting Started +A unified platform for three lead-scraping pipelines with email enrichment via Anymailfinder. -First, run the development server: +--- + +## Tech Stack + +- **Next.js 16** (App Router) + TypeScript +- **SQLite** via Prisma 7 + better-sqlite3 +- **shadcn/ui** + Tailwind CSS +- **Anymailfinder API v5.1** — email enrichment (bulk JSON + individual search) +- **Vayne API** — LinkedIn Sales Navigator scraping +- **Apify** — Google SERP scraping + +--- + +## Setup + +### 1. Install dependencies + +```bash +cd leadflow +npm install +``` + +### 2. Configure environment + +Copy `.env.local.example` to `.env.local`: + +```bash +cp .env.local.example .env.local +``` + +Edit `.env` and `.env.local`: + +```env +APP_ENCRYPTION_SECRET=your-32-character-secret-here!! +DATABASE_URL=file:./leadflow.db +``` + +### 3. Run database migration + +```bash +npx prisma migrate dev +``` + +### 4. Start the app ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:3000](http://localhost:3000) -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +--- -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## API Keys — Where to Get Them -## Learn More +Go to **Settings** in the sidebar to enter and save credentials. All keys are AES-256 encrypted before storage. -To learn more about Next.js, take a look at the following resources: +### Anymailfinder +- Sign up at [anymailfinder.com](https://anymailfinder.com) +- Account → API → copy your key (format: starts with your account prefix) +- Pricing: 2 credits/valid decision maker email, 1 credit/person email +- Bulk API charges only when downloading results -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### Apify +- Sign up at [apify.com](https://apify.com) +- Console → Account → Integrations → API tokens +- The app uses the `apify/google-search-scraper` actor (pay-per-event) -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +### Vayne +- Sign up at [vayne.io](https://vayne.io) +- Dashboard → API Settings → generate token +- **Connect LinkedIn** in the Vayne dashboard — Vayne manages the LinkedIn session on their end +- No need to manually export cookies -## Deploy on Vercel +--- -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Pipeline Workflows -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### Tab 1 — AirScale → Email +1. Export companies from AirScale as CSV +2. Upload → map domain column +3. Select decision maker categories +4. Start Enrichment → bulk API runs asynchronously +5. Export CSV/Excel + +### Tab 2 — LinkedIn Sales Navigator → Email +1. Build search in Sales Navigator, copy URL +2. Paste URL + set max results → Start Scrape +3. Vayne scrapes profiles (polls until done) +4. Select profiles → Enrich with Emails +5. Export results + +### Tab 3 — SERP → Email +1. Enter search term (e.g. `"Solaranlage Installateur Deutschland"`) +2. Set country/language, enable social filter +3. Select decision maker categories +4. Start → Apify scrapes Google, domains extracted, then bulk enriched +5. Export results + +--- + +## How Anymailfinder Bulk Works + +1. All domains submitted as one POST to `/v5.1/bulk/json` +2. Poll status every 5s until `completed` +3. Download results once (credits charged at download, not submission) +4. Speed: ~1,000 domains per 5 minutes + +--- + +## Database + +SQLite at `./leadflow.db`. Inspect with: + +```bash +npx prisma studio +``` + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "API key not configured" | Add key in Settings | +| Job stuck at "running" | Check server console (`npm run dev` terminal) | +| Prisma errors on build | Run `npx prisma generate && npm run build` | diff --git a/app/airscale/page.tsx b/app/airscale/page.tsx new file mode 100644 index 0000000..b92c0af --- /dev/null +++ b/app/airscale/page.tsx @@ -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[]>([]); + const [headers, setHeaders] = useState([]); + const [domainCol, setDomainCol] = useState(""); + const [nameCol, setNameCol] = useState(""); + const [categories, setCategories] = useState(DEFAULT_ROLES); + const [jobId, setJobId] = useState(null); + const [jobStatus, setJobStatus] = useState("idle"); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [results, setResults] = useState([]); + 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 ( +
+ {/* Header */} +
+
+
+
+ + Tab 1 + + AirScale Companies +
+

AirScale → Email Enrichment

+

+ Upload an AirScale CSV export and find decision maker emails via Anymailfinder. +

+
+
+ + {/* Step 1: Upload */} + +

+ 1 + Upload AirScale CSV +

+ + + {csvData.length > 0 && ( +
+ {/* Stats */} +
+ {[ + { 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 => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Column mapper */} +
+
+ + +
+
+ + +
+
+ + {/* Preview */} +
+
+ Preview (first 5 rows) +
+
+ + + + {headers.slice(0, 6).map(h => ( + + ))} + + + + {csvData.slice(0, 5).map((row, i) => ( + + {headers.slice(0, 6).map(h => ( + + ))} + + ))} + +
{h}
+ {row[h] || "—"} +
+
+
+
+ )} +
+ + {/* Step 2: Configure */} + +

+ 2 + Decision Maker Categories +

+ +
+ +
+ {CATEGORY_OPTIONS.map(opt => ( + + ))} +
+

+ Categories are searched in priority order. First category with a valid result wins. +

+
+
+ + {/* Step 3: Run */} + +

+ 3 + Run Enrichment +

+ + {!running && jobStatus === "idle" && ( + csvData.length === 0 ? ( + + ) : ( + + ) + )} + + {(running || jobStatus === "running") && ( + + )} + + {jobStatus === "complete" && ( + + )} + + {jobStatus === "failed" && ( +
+ + Enrichment failed. Check your API key in Settings. +
+ )} +
+ + {/* Results */} + {results.length > 0 && ( + +
+

+ 4 + Results +

+ r.email).length} emails found • ${hitRate}% hit rate`} + /> +
+ +
+ )} +
+ ); +} diff --git a/app/api/credentials/route.ts b/app/api/credentials/route.ts new file mode 100644 index 0000000..9610bbc --- /dev/null +++ b/app/api/credentials/route.ts @@ -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 = {}; + 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; + 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 }); + } +} diff --git a/app/api/credentials/test/route.ts b/app/api/credentials/test/route.ts new file mode 100644 index 0000000..eda01d3 --- /dev/null +++ b/app/api/credentials/test/route.ts @@ -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 }); + } +} diff --git a/app/api/export/[jobId]/route.ts b/app/api/export/[jobId]/route.ts new file mode 100644 index 0000000..9dd2558 --- /dev/null +++ b/app/api/export/[jobId]/route.ts @@ -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 }); + } +} diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts new file mode 100644 index 0000000..152d0f1 --- /dev/null +++ b/app/api/jobs/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/jobs/[id]/status/route.ts b/app/api/jobs/[id]/status/route.ts new file mode 100644 index 0000000..3318f19 --- /dev/null +++ b/app/api/jobs/[id]/status/route.ts @@ -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 }); + } +} diff --git a/app/api/jobs/airscale-enrich/route.ts b/app/api/jobs/airscale-enrich/route.ts new file mode 100644 index 0000000..f2b43b9 --- /dev/null +++ b/app/api/jobs/airscale-enrich/route.ts @@ -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(); + 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, + 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 }, + }); + } +} diff --git a/app/api/jobs/linkedin-enrich/route.ts b/app/api/jobs/linkedin-enrich/route.ts new file mode 100644 index 0000000..dbe1255 --- /dev/null +++ b/app/api/jobs/linkedin-enrich/route.ts @@ -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)); +} diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts new file mode 100644 index 0000000..7ff622c --- /dev/null +++ b/app/api/jobs/route.ts @@ -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 }); + } +} diff --git a/app/api/jobs/serp-enrich/route.ts b/app/api/jobs/serp-enrich/route.ts new file mode 100644 index 0000000..53a75bb --- /dev/null +++ b/app/api/jobs/serp-enrich/route.ts @@ -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(); + 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)); +} diff --git a/app/api/jobs/vayne-scrape/route.ts b/app/api/jobs/vayne-scrape/route.ts new file mode 100644 index 0000000..28f40f7 --- /dev/null +++ b/app/api/jobs/vayne-scrape/route.ts @@ -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)); +} diff --git a/app/globals.css b/app/globals.css index a8da733..95e71d7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,129 +1,30 @@ @import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.809 0.105 251.813); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: #0a0a0f; + --card: #111118; + --border: #1e1e2e; + --primary: #3b82f6; + --secondary: #8b5cf6; + --success: #22c55e; + --warning: #f59e0b; + --error: #ef4444; + --foreground: #f0f0f5; + --muted: #6b7280; } -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.809 0.105 251.813); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); +* { + border-color: var(--border); } -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - html { - @apply font-sans; - } -} \ No newline at end of file +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #0a0a0f; } +::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #3b82f6; } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..72131fb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,33 +1,31 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter } from "next/font/google"; import "./globals.css"; +import { Sidebar } from "@/components/layout/Sidebar"; +import { TopBar } from "@/components/layout/TopBar"; +import { Toaster } from "@/components/ui/sonner"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "LeadFlow — Lead Generation Platform", + description: "Unified lead generation and email enrichment platform", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} + + +
+ +
+ +
+ {children} +
+
+
+ ); diff --git a/app/linkedin/page.tsx b/app/linkedin/page.tsx new file mode 100644 index 0000000..e8b3e18 --- /dev/null +++ b/app/linkedin/page.tsx @@ -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(null); + const [guideOpen, setGuideOpen] = useState(false); + const [stage, setStage] = useState("idle"); + const [scrapeJobId, setScrapeJobId] = useState(null); + const [enrichJobId, setEnrichJobId] = useState(null); + const [scrapeProgress, setScrapeProgress] = useState({ current: 0, total: 0 }); + const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 }); + const [results, setResults] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [categories, setCategories] = useState(["ceo"]); + const { addJob, updateJob, removeJob } = useAppStore(); + + useEffect(() => { + fetch("/api/credentials") + .then(r => r.json()) + .then((d: Record) => 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 ( +
+ {/* Header */} +
+
+
+
+ + Tab 2 + + LinkedIn Sales Navigator +
+

LinkedIn → Email Pipeline

+

+ Scrape Sales Navigator profiles via Vayne, then enrich with Anymailfinder. +

+
+
+ + {/* Filter Guide */} + + + {guideOpen && ( +
+
+
+

Keywords

+
+ {["Solarlösungen", "Founder", "Co-Founder", "CEO", "Geschäftsführer"].map(k => ( + {k} + ))} +
+
+
+

Headcount

+
+ {["1–10", "11–50", "51–200"].map(k => ( + {k} + ))} +
+
+
+

Country

+ Germany (Deutschland) +
+
+

Target Titles

+
+ {["Founder", "Co-Founder", "CEO", "CTO", "COO", "Owner", "President", "Principal", "Partner"].map(k => ( + {k} + ))} +
+
+
+
+ )} +
+ + {/* Step 1: Input */} + +

+ 1 + Sales Navigator URL +

+ + {/* Vayne status */} +
+ {vayneConfigured === null ? "Checking Vayne configuration..." + : vayneConfigured ? ( + <> Vayne API token configured + ) : ( + <> Vayne token not configured — go to Settings + )} +
+ +
+ +