Files
lead-scraper/app/linkedin/page.tsx
Timo Uttenweiler f6bdc65b1e feat: übersetze gesamte UI auf Deutsch
- Alle Seiten (AirScale, LinkedIn, SERP, Ergebnisse, Einstellungen) auf Deutsch
- Gemeinsame Komponenten übersetzt: StatusBadge, ResultsTable-Spalten, FileDropZone, ExportButtons
- Sidebar API-Status-Label und TopBar-Breadcrumbs auf Deutsch
- Alle Toast-Nachrichten und Fehlermeldungen auf Deutsch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:40:05 +01:00

420 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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("Bitte gültige Sales Navigator URL einfügen");
if (!vayneConfigured) return toast.error("Bitte zuerst Vayne API-Token in den Einstellungen konfigurieren");
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 : "Scraping konnte nicht gestartet werden");
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(`${data.totalLeads} Profile von LinkedIn gescrapt`);
} else {
setStage("failed");
toast.error("Scraping fehlgeschlagen. Bitte Vayne-Token in den Einstellungen prüfen.");
}
}
} catch {
clearInterval(interval);
setStage("failed");
}
}, 2000);
};
const startEnrich = async () => {
if (!selectedIds.length) return toast.error("Mindestens ein Profil zum Anreichern auswählen");
if (!categories.length) return toast.error("Mindestens eine Kategorie auswählen");
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 : "Anreicherung konnte nicht gestartet werden");
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(`${data.emailsFound} E-Mails gefunden`);
} else {
setStage("scraped");
toast.error("Anreicherung fehlgeschlagen");
}
}
} 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 E-Mail Pipeline</h1>
<p className="text-gray-400 mt-1 text-sm">
Scrape Sales Navigator-Profile über Vayne und reichere sie mit Anymailfinder an.
</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" />
Empfohlene Sales Navigator Filtereinstellungen
</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">Schlüsselwörter</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">Mitarbeiterzahl</p>
<div className="flex flex-wrap gap-1.5">
{["110", "1150", "51200"].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">Land</p>
<span className="bg-green-500/10 text-green-300 px-2 py-0.5 rounded text-xs">Deutschland</span>
</div>
<div>
<p className="text-gray-400 font-medium mb-2">Zielpositionen</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 ? "Vayne-Konfiguration wird geprüft..."
: vayneConfigured ? (
<><CheckCircle2 className="w-4 h-4" /> Vayne API-Token konfiguriert</>
) : (
<><XCircle className="w-4 h-4" /> Vayne-Token nicht konfiguriert <a href="/settings" className="underline">zu den Einstellungen</a></>
)}
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Sales Navigator Such-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">Muss eine linkedin.com/sales/search URL sein</p>
)}
</div>
<div>
<Label className="text-gray-300 text-sm mb-1.5 block">Maximale Ergebnisse</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">
Diesen Tab nicht schließen, solange das Scraping läuft. Der Job läuft im Hintergrund,
aber die Fortschrittsanzeige benötigt diesen Tab.
</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"
>
LinkedIn-Scraping starten
</Button>
</Card>
{/* Scrape progress */}
{(stage === "scraping") && (
<ProgressCard
title="LinkedIn-Profile werden über Vayne gescrapt..."
current={scrapeProgress.current}
total={scrapeProgress.total}
subtitle="Auftrag erstellen → Scraping → Export generieren..."
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>
Gescrapte Profile ({results.length})
</h2>
<p className="text-xs text-gray-500">Profile für die E-Mail-Anreicherung auswählen</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>
Mit E-Mails anreichern
</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="Profile werden angereichert..."
current={enrichProgress.current}
total={enrichProgress.total}
subtitle="E-Mails werden über Anymailfinder gesucht"
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"
>
{selectedIds.length} ausgewählte Profile mit E-Mails anreichern
</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} E-Mails aus ${results.length} Profilen gefunden`}
/>
</Card>
)}
{/* Empty state */}
{stage === "idle" && (
<EmptyState
icon={Linkedin}
title="Sales Navigator URL einfügen"
description="Suchfilter in Sales Navigator konfigurieren, URL kopieren und oben einfügen."
/>
)}
</div>
);
}