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>
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|