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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user