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>
This commit is contained in:
Timo Uttenweiler
2026-03-17 12:40:05 +01:00
parent 7486517827
commit f6bdc65b1e
11 changed files with 160 additions and 160 deletions

View File

@@ -73,8 +73,8 @@ export default function AirScalePage() {
const withoutDomain = csvData.length - withDomain; const withoutDomain = csvData.length - withDomain;
const startEnrichment = async () => { const startEnrichment = async () => {
if (!companies.length) return toast.error("No companies with domains found"); if (!companies.length) return toast.error("Keine Unternehmen mit Domains gefunden");
if (!categories.length) return toast.error("Select at least one decision maker category"); if (!categories.length) return toast.error("Mindestens eine Entscheider-Kategorie auswählen");
setRunning(true); setRunning(true);
setResults([]); setResults([]);
@@ -91,7 +91,7 @@ export default function AirScalePage() {
setJobId(data.jobId); setJobId(data.jobId);
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length }); addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
toast.success("Enrichment started!"); toast.success("Anreicherung gestartet!");
pollJob(data.jobId); pollJob(data.jobId);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start"); toast.error(err instanceof Error ? err.message : "Failed to start");
@@ -119,9 +119,9 @@ export default function AirScalePage() {
setRunning(false); setRunning(false);
removeJob(id); removeJob(id);
if (data.status === "complete") { if (data.status === "complete") {
toast.success(`Done! Found ${data.emailsFound} emails from ${data.totalLeads} companies`); toast.success(`Fertig! ${data.emailsFound} E-Mails aus ${data.totalLeads} Unternehmen gefunden`);
} else { } else {
toast.error(`Job failed: ${data.error || "Unknown error"}`); toast.error(`Job fehlgeschlagen: ${data.error || "Unbekannter Fehler"}`);
} }
} }
} catch { } catch {
@@ -159,9 +159,9 @@ export default function AirScalePage() {
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
<span>AirScale Companies</span> <span>AirScale Companies</span>
</div> </div>
<h1 className="text-2xl font-bold text-white">AirScale Email Enrichment</h1> <h1 className="text-2xl font-bold text-white">AirScale E-Mail Anreicherung</h1>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
Upload an AirScale CSV export and find decision maker emails via Anymailfinder. Lade einen AirScale CSV-Export hoch und finde Entscheider-E-Mails über Anymailfinder.
</p> </p>
</div> </div>
</div> </div>
@@ -172,16 +172,16 @@ export default function AirScalePage() {
<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> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">1</span>
Upload AirScale CSV Upload AirScale CSV
</h2> </h2>
<FileDropZone onFile={onFile} label="Drop your AirScale CSV export here" /> <FileDropZone onFile={onFile} label="AirScale CSV-Export hier ablegen" />
{csvData.length > 0 && ( {csvData.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats */} {/* Stats */}
<div className="flex gap-4"> <div className="flex gap-4">
{[ {[
{ label: "Total rows", value: csvData.length, color: "text-white" }, { label: "Zeilen gesamt", value: csvData.length, color: "text-white" },
{ label: "With domains", value: withDomain, color: "text-green-400" }, { label: "Mit Domain", value: withDomain, color: "text-green-400" },
{ label: "Missing domains", value: withoutDomain, color: "text-yellow-400" }, { label: "Ohne Domain", value: withoutDomain, color: "text-yellow-400" },
].map(stat => ( ].map(stat => (
<div key={stat.label} className="bg-[#0d0d18] rounded-lg px-4 py-2.5 border border-[#1e1e2e]"> <div key={stat.label} className="bg-[#0d0d18] rounded-lg px-4 py-2.5 border border-[#1e1e2e]">
<p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p> <p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p>
@@ -193,10 +193,10 @@ export default function AirScalePage() {
{/* Column mapper */} {/* Column mapper */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Domain Column</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Domain-Spalte</Label>
<Select value={domainCol} onValueChange={v => setDomainCol(v ?? "")}> <Select value={domainCol} onValueChange={v => setDomainCol(v ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white"> <SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select domain column..." /> <SelectValue placeholder="Domain-Spalte auswählen..." />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent className="bg-[#111118] border-[#2e2e3e]">
{headers.map(h => ( {headers.map(h => (
@@ -206,13 +206,13 @@ export default function AirScalePage() {
</Select> </Select>
</div> </div>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Company Name Column (optional)</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Firmenname-Spalte (optional)</Label>
<Select value={nameCol} onValueChange={v => setNameCol(v ?? "")}> <Select value={nameCol} onValueChange={v => setNameCol(v ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white"> <SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select name column..." /> <SelectValue placeholder="Namensspalte auswählen..." />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent className="bg-[#111118] border-[#2e2e3e]">
<SelectItem value="" className="text-gray-400">None</SelectItem> <SelectItem value="" className="text-gray-400">Keine</SelectItem>
{headers.map(h => ( {headers.map(h => (
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem> <SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
))} ))}
@@ -224,7 +224,7 @@ export default function AirScalePage() {
{/* Preview */} {/* Preview */}
<div className="rounded-lg border border-[#1e1e2e] overflow-hidden"> <div className="rounded-lg border border-[#1e1e2e] overflow-hidden">
<div className="bg-[#0d0d18] px-4 py-2 text-xs text-gray-500 border-b border-[#1e1e2e]"> <div className="bg-[#0d0d18] px-4 py-2 text-xs text-gray-500 border-b border-[#1e1e2e]">
Preview (first 5 rows) Vorschau (erste 5 Zeilen)
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
@@ -257,11 +257,11 @@ export default function AirScalePage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <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> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories Entscheider-Kategorien
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-gray-300 text-sm">Select categories to search (in order of priority)</Label> <Label className="text-gray-300 text-sm">Kategorien auswählen (nach Priorität sortiert)</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => ( {CATEGORY_OPTIONS.map(opt => (
<button <button
@@ -284,7 +284,7 @@ export default function AirScalePage() {
))} ))}
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Categories are searched in priority order. First category with a valid result wins. Kategorien werden in Prioritätsreihenfolge durchsucht. Die erste Kategorie mit einem gültigen Ergebnis gewinnt.
</p> </p>
</div> </div>
</Card> </Card>
@@ -293,15 +293,15 @@ export default function AirScalePage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <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> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
Run Enrichment Anreicherung starten
</h2> </h2>
{!running && jobStatus === "idle" && ( {!running && jobStatus === "idle" && (
csvData.length === 0 ? ( csvData.length === 0 ? (
<EmptyState <EmptyState
icon={Building2} icon={Building2}
title="Upload a CSV to get started" title="CSV hochladen um zu starten"
description="Upload your AirScale export above, then configure and run enrichment." description="Lade deinen AirScale-Export oben hoch, konfiguriere und starte die Anreicherung."
/> />
) : ( ) : (
<Button <Button
@@ -309,27 +309,27 @@ export default function AirScalePage() {
disabled={!withDomain || !domainCol || !categories.length} disabled={!withDomain || !domainCol || !categories.length}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all" className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
> >
Start Enrichment ({withDomain} companies) Anreicherung starten ({withDomain} Unternehmen)
</Button> </Button>
) )
)} )}
{(running || jobStatus === "running") && ( {(running || jobStatus === "running") && (
<ProgressCard <ProgressCard
title="Enriching companies..." title="Unternehmen werden angereichert..."
current={progress.current} current={progress.current}
total={progress.total || withDomain} total={progress.total || withDomain}
subtitle="Finding decision maker emails via Anymailfinder" subtitle="Entscheider-E-Mails werden über Anymailfinder gesucht"
status="running" status="running"
/> />
)} )}
{jobStatus === "complete" && ( {jobStatus === "complete" && (
<ProgressCard <ProgressCard
title="Enrichment complete" title="Anreicherung abgeschlossen"
current={progress.current} current={progress.current}
total={progress.total} total={progress.total}
subtitle={`Hit rate: ${hitRate}%`} subtitle={`Trefferquote: ${hitRate}%`}
status="complete" status="complete"
/> />
)} )}
@@ -337,7 +337,7 @@ export default function AirScalePage() {
{jobStatus === "failed" && ( {jobStatus === "failed" && (
<div className="flex items-center gap-2 text-red-400 text-sm"> <div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
Enrichment failed. Check your API key in Settings. Anreicherung fehlgeschlagen. Bitte API-Key in den Einstellungen prüfen.
</div> </div>
)} )}
</Card> </Card>
@@ -348,12 +348,12 @@ export default function AirScalePage() {
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">4</span> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">4</span>
Results Ergebnisse
</h2> </h2>
<ExportButtons <ExportButtons
rows={exportRows} rows={exportRows}
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`} filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${results.filter(r => r.email).length} emails found • ${hitRate}% hit rate`} summary={`${results.filter(r => r.email).length} E-Mails gefunden${hitRate}% Trefferquote`}
/> />
</div> </div>
<ResultsTable rows={results} loading={running && results.length === 0} /> <ResultsTable rows={results} loading={running && results.length === 0} />

View File

@@ -59,8 +59,8 @@ export default function LinkedInPage() {
const urlValid = salesNavUrl.includes("linkedin.com/sales"); const urlValid = salesNavUrl.includes("linkedin.com/sales");
const startScrape = async () => { const startScrape = async () => {
if (!urlValid) return toast.error("Please paste a valid Sales Navigator URL"); if (!urlValid) return toast.error("Bitte gültige Sales Navigator URL einfügen");
if (!vayneConfigured) return toast.error("Configure your Vayne API token in Settings first"); if (!vayneConfigured) return toast.error("Bitte zuerst Vayne API-Token in den Einstellungen konfigurieren");
setStage("scraping"); setStage("scraping");
setScrapeProgress({ current: 0, total: maxResults }); setScrapeProgress({ current: 0, total: maxResults });
@@ -79,7 +79,7 @@ export default function LinkedInPage() {
addJob({ id: data.jobId, type: "linkedin-scrape", status: "running", progress: 0, total: maxResults }); addJob({ id: data.jobId, type: "linkedin-scrape", status: "running", progress: 0, total: maxResults });
pollScrape(data.jobId); pollScrape(data.jobId);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start scrape"); toast.error(err instanceof Error ? err.message : "Scraping konnte nicht gestartet werden");
setStage("failed"); setStage("failed");
} }
}; };
@@ -104,10 +104,10 @@ export default function LinkedInPage() {
setStage("scraped"); setStage("scraped");
setResults(data.results || []); setResults(data.results || []);
setSelectedIds(data.results?.map(r => r.id) || []); setSelectedIds(data.results?.map(r => r.id) || []);
toast.success(`Scraped ${data.totalLeads} profiles from LinkedIn`); toast.success(`${data.totalLeads} Profile von LinkedIn gescrapt`);
} else { } else {
setStage("failed"); setStage("failed");
toast.error("Scrape failed. Check Vayne token in Settings."); toast.error("Scraping fehlgeschlagen. Bitte Vayne-Token in den Einstellungen prüfen.");
} }
} }
} catch { } catch {
@@ -118,8 +118,8 @@ export default function LinkedInPage() {
}; };
const startEnrich = async () => { const startEnrich = async () => {
if (!selectedIds.length) return toast.error("Select at least one profile to enrich"); if (!selectedIds.length) return toast.error("Mindestens ein Profil zum Anreichern auswählen");
if (!categories.length) return toast.error("Select at least one category"); if (!categories.length) return toast.error("Mindestens eine Kategorie auswählen");
setStage("enriching"); setStage("enriching");
setEnrichProgress({ current: 0, total: selectedIds.length }); setEnrichProgress({ current: 0, total: selectedIds.length });
@@ -137,7 +137,7 @@ export default function LinkedInPage() {
addJob({ id: data.jobId, type: "linkedin-enrich", status: "running", progress: 0, total: selectedIds.length }); addJob({ id: data.jobId, type: "linkedin-enrich", status: "running", progress: 0, total: selectedIds.length });
pollEnrich(data.jobId); pollEnrich(data.jobId);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start enrichment"); toast.error(err instanceof Error ? err.message : "Anreicherung konnte nicht gestartet werden");
setStage("scraped"); setStage("scraped");
} }
}; };
@@ -163,10 +163,10 @@ export default function LinkedInPage() {
if (data.status === "complete") { if (data.status === "complete") {
setStage("done"); setStage("done");
toast.success(`Found ${data.emailsFound} emails`); toast.success(`${data.emailsFound} E-Mails gefunden`);
} else { } else {
setStage("scraped"); setStage("scraped");
toast.error("Enrichment failed"); toast.error("Anreicherung fehlgeschlagen");
} }
} }
} catch { } catch {
@@ -201,9 +201,9 @@ export default function LinkedInPage() {
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
<span>LinkedIn Sales Navigator</span> <span>LinkedIn Sales Navigator</span>
</div> </div>
<h1 className="text-2xl font-bold text-white">LinkedIn Email Pipeline</h1> <h1 className="text-2xl font-bold text-white">LinkedIn E-Mail Pipeline</h1>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
Scrape Sales Navigator profiles via Vayne, then enrich with Anymailfinder. Scrape Sales Navigator-Profile über Vayne und reichere sie mit Anymailfinder an.
</p> </p>
</div> </div>
</div> </div>
@@ -216,7 +216,7 @@ export default function LinkedInPage() {
> >
<div className="flex items-center gap-2 text-purple-300 font-medium"> <div className="flex items-center gap-2 text-purple-300 font-medium">
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
Recommended Sales Navigator Filter Settings Empfohlene Sales Navigator Filtereinstellungen
</div> </div>
{guideOpen ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />} {guideOpen ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
</button> </button>
@@ -224,7 +224,7 @@ export default function LinkedInPage() {
<div className="px-6 pb-5 border-t border-purple-500/10 space-y-4 text-sm"> <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 className="grid grid-cols-2 gap-4 pt-4">
<div> <div>
<p className="text-gray-400 font-medium mb-2">Keywords</p> <p className="text-gray-400 font-medium mb-2">Schlüsselwörter</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{["Solarlösungen", "Founder", "Co-Founder", "CEO", "Geschäftsführer"].map(k => ( {["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> <span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
@@ -232,7 +232,7 @@ export default function LinkedInPage() {
</div> </div>
</div> </div>
<div> <div>
<p className="text-gray-400 font-medium mb-2">Headcount</p> <p className="text-gray-400 font-medium mb-2">Mitarbeiterzahl</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{["110", "1150", "51200"].map(k => ( {["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> <span key={k} className="bg-blue-500/10 text-blue-300 px-2 py-0.5 rounded text-xs">{k}</span>
@@ -240,11 +240,11 @@ export default function LinkedInPage() {
</div> </div>
</div> </div>
<div> <div>
<p className="text-gray-400 font-medium mb-2">Country</p> <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">Germany (Deutschland)</span> <span className="bg-green-500/10 text-green-300 px-2 py-0.5 rounded text-xs">Deutschland</span>
</div> </div>
<div> <div>
<p className="text-gray-400 font-medium mb-2">Target Titles</p> <p className="text-gray-400 font-medium mb-2">Zielpositionen</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{["Founder", "Co-Founder", "CEO", "CTO", "COO", "Owner", "President", "Principal", "Partner"].map(k => ( {["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> <span key={k} className="bg-purple-500/10 text-purple-300 px-2 py-0.5 rounded text-xs">{k}</span>
@@ -269,16 +269,16 @@ export default function LinkedInPage() {
: vayneConfigured ? "bg-green-500/5 border-green-500/20 text-green-400" : vayneConfigured ? "bg-green-500/5 border-green-500/20 text-green-400"
: "bg-red-500/5 border-red-500/20 text-red-400" : "bg-red-500/5 border-red-500/20 text-red-400"
}`}> }`}>
{vayneConfigured === null ? "Checking Vayne configuration..." {vayneConfigured === null ? "Vayne-Konfiguration wird geprüft..."
: vayneConfigured ? ( : vayneConfigured ? (
<><CheckCircle2 className="w-4 h-4" /> Vayne API token configured</> <><CheckCircle2 className="w-4 h-4" /> Vayne API-Token konfiguriert</>
) : ( ) : (
<><XCircle className="w-4 h-4" /> Vayne token not configured <a href="/settings" className="underline">go to Settings</a></> <><XCircle className="w-4 h-4" /> Vayne-Token nicht konfiguriert <a href="/settings" className="underline">zu den Einstellungen</a></>
)} )}
</div> </div>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Sales Navigator Search URL</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Sales Navigator Such-URL</Label>
<Textarea <Textarea
placeholder="https://www.linkedin.com/sales/search/people?query=..." placeholder="https://www.linkedin.com/sales/search/people?query=..."
value={salesNavUrl} value={salesNavUrl}
@@ -286,12 +286,12 @@ export default function LinkedInPage() {
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20 font-mono text-xs" 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 && ( {salesNavUrl && !urlValid && (
<p className="text-xs text-red-400 mt-1">Must be a linkedin.com/sales/search URL</p> <p className="text-xs text-red-400 mt-1">Muss eine linkedin.com/sales/search URL sein</p>
)} )}
</div> </div>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Max results to scrape</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Maximale Ergebnisse</Label>
<Input <Input
type="number" type="number"
min={1} min={1}
@@ -306,8 +306,8 @@ export default function LinkedInPage() {
<div className="flex items-start gap-2 bg-yellow-500/5 border border-yellow-500/20 rounded-lg px-4 py-3"> <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" /> <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-yellow-300"> <p className="text-xs text-yellow-300">
Do not close this tab while scraping is in progress. The job runs in the background Diesen Tab nicht schließen, solange das Scraping läuft. Der Job läuft im Hintergrund,
but progress tracking requires this tab to remain open. aber die Fortschrittsanzeige benötigt diesen Tab.
</p> </p>
</div> </div>
@@ -316,17 +316,17 @@ export default function LinkedInPage() {
disabled={!urlValid || !vayneConfigured || stage === "scraping"} 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" className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all disabled:opacity-50"
> >
Start LinkedIn Scrape LinkedIn-Scraping starten
</Button> </Button>
</Card> </Card>
{/* Scrape progress */} {/* Scrape progress */}
{(stage === "scraping") && ( {(stage === "scraping") && (
<ProgressCard <ProgressCard
title="Scraping LinkedIn profiles via Vayne..." title="LinkedIn-Profile werden über Vayne gescrapt..."
current={scrapeProgress.current} current={scrapeProgress.current}
total={scrapeProgress.total} total={scrapeProgress.total}
subtitle="Creating order → Scraping → Generating export..." subtitle="Auftrag erstellen → Scraping → Export generieren..."
status="running" status="running"
/> />
)} )}
@@ -336,9 +336,9 @@ export default function LinkedInPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <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> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">2</span>
Scraped Profiles ({results.length}) Gescrapte Profile ({results.length})
</h2> </h2>
<p className="text-xs text-gray-500">Select profiles to include in email enrichment</p> <p className="text-xs text-gray-500">Profile für die E-Mail-Anreicherung auswählen</p>
<ResultsTable <ResultsTable
rows={results} rows={results}
selectable selectable
@@ -352,7 +352,7 @@ export default function LinkedInPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <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> <span className="w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 text-xs flex items-center justify-center font-bold">3</span>
Enrich with Emails Mit E-Mails anreichern
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -375,10 +375,10 @@ export default function LinkedInPage() {
{stage === "enriching" && ( {stage === "enriching" && (
<ProgressCard <ProgressCard
title="Enriching profiles..." title="Profile werden angereichert..."
current={enrichProgress.current} current={enrichProgress.current}
total={enrichProgress.total} total={enrichProgress.total}
subtitle="Finding emails via Anymailfinder" subtitle="E-Mails werden über Anymailfinder gesucht"
status="running" status="running"
/> />
)} )}
@@ -389,7 +389,7 @@ export default function LinkedInPage() {
disabled={!selectedIds.length || !categories.length} 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" className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
> >
Enrich {selectedIds.length} Selected Profiles with Emails {selectedIds.length} ausgewählte Profile mit E-Mails anreichern
</Button> </Button>
)} )}
</Card> </Card>
@@ -401,7 +401,7 @@ export default function LinkedInPage() {
<ExportButtons <ExportButtons
rows={exportRows} rows={exportRows}
filename={`linkedin-leads-${scrapeJobId?.slice(0, 8) || "export"}`} filename={`linkedin-leads-${scrapeJobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found from ${results.length} profiles`} summary={`${emailsFound} E-Mails aus ${results.length} Profilen gefunden`}
/> />
</Card> </Card>
)} )}
@@ -410,8 +410,8 @@ export default function LinkedInPage() {
{stage === "idle" && ( {stage === "idle" && (
<EmptyState <EmptyState
icon={Linkedin} icon={Linkedin}
title="Start by pasting a Sales Navigator URL" title="Sales Navigator URL einfügen"
description="Configure your search filters in Sales Navigator, copy the URL, and paste it above to begin scraping." description="Suchfilter in Sales Navigator konfigurieren, URL kopieren und oben einfügen."
/> />
)} )}
</div> </div>

View File

@@ -52,7 +52,7 @@ export default function ResultsPage() {
setJobs(data.jobs || []); setJobs(data.jobs || []);
setStats(data.stats || { totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 }); setStats(data.stats || { totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
} catch { } catch {
toast.error("Failed to load job history"); toast.error("Job-Verlauf konnte nicht geladen werden");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -62,9 +62,9 @@ export default function ResultsPage() {
try { try {
await fetch(`/api/jobs/${id}`, { method: "DELETE" }); await fetch(`/api/jobs/${id}`, { method: "DELETE" });
setJobs(prev => prev.filter(j => j.id !== id)); setJobs(prev => prev.filter(j => j.id !== id));
toast.success("Job deleted"); toast.success("Job gelöscht");
} catch { } catch {
toast.error("Failed to delete job"); toast.error("Job konnte nicht gelöscht werden");
} }
}; };
@@ -81,18 +81,18 @@ export default function ResultsPage() {
<div className="relative rounded-2xl bg-gradient-to-r from-green-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden"> <div className="relative rounded-2xl bg-gradient-to-r from-green-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative"> <div className="relative">
<h1 className="text-2xl font-bold text-white">Results & History</h1> <h1 className="text-2xl font-bold text-white">Ergebnisse & Verlauf</h1>
<p className="text-gray-400 mt-1 text-sm">All past enrichment jobs and their results.</p> <p className="text-gray-400 mt-1 text-sm">Alle vergangenen Anreicherungsjobs und ihre Ergebnisse.</p>
</div> </div>
</div> </div>
{/* Stats cards */} {/* Stats cards */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{[ {[
{ label: "Total Jobs", value: stats.totalJobs, color: "text-white" }, { label: "Jobs gesamt", value: stats.totalJobs, color: "text-white" },
{ label: "Total Leads", value: stats.totalLeads.toLocaleString(), color: "text-blue-400" }, { label: "Leads gesamt", value: stats.totalLeads.toLocaleString(), color: "text-blue-400" },
{ label: "Emails Found", value: stats.totalEmails.toLocaleString(), color: "text-green-400" }, { label: "E-Mails gefunden", value: stats.totalEmails.toLocaleString(), color: "text-green-400" },
{ label: "Avg Hit Rate", value: `${stats.avgHitRate}%`, color: "text-purple-400" }, { label: "Ø Trefferquote", value: `${stats.avgHitRate}%`, color: "text-purple-400" },
].map(stat => ( ].map(stat => (
<Card key={stat.label} className="bg-[#111118] border-[#1e1e2e] p-5"> <Card key={stat.label} className="bg-[#111118] border-[#1e1e2e] p-5">
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p> <p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
@@ -104,14 +104,14 @@ export default function ResultsPage() {
{/* Jobs table */} {/* Jobs table */}
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden"> <Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1e1e2e]"> <div className="flex items-center justify-between px-6 py-4 border-b border-[#1e1e2e]">
<h2 className="font-semibold text-white">Job History</h2> <h2 className="font-semibold text-white">Job-Verlauf</h2>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={loadJobs} onClick={loadJobs}
className="border-[#2e2e3e] text-gray-300 hover:bg-[#1a1a28]" className="border-[#2e2e3e] text-gray-300 hover:bg-[#1a1a28]"
> >
<RefreshCw className="w-3.5 h-3.5 mr-1.5" /> Refresh <RefreshCw className="w-3.5 h-3.5 mr-1.5" /> Aktualisieren
</Button> </Button>
</div> </div>
@@ -124,22 +124,22 @@ export default function ResultsPage() {
) : jobs.length === 0 ? ( ) : jobs.length === 0 ? (
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title="No jobs yet" title="Noch keine Jobs"
description="Run an enrichment job from any of the pipeline tabs to see results here." description="Starte einen Anreicherungsjob über einen der Pipeline-Tabs, um Ergebnisse zu sehen."
/> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]"> <tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Type</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Job ID</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Job-ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Started</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Gestartet</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Emails</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mails</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Hit Rate</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Trefferquote</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -51,12 +51,12 @@ export default function SerpPage() {
const maxPages = Math.max(1, Math.ceil(numResults / 10)); const maxPages = Math.max(1, Math.ceil(numResults / 10));
const startJob = async () => { const startJob = async () => {
if (!query.trim()) return toast.error("Enter a search query"); if (!query.trim()) return toast.error("Suchbegriff eingeben");
if (!categories.length) return toast.error("Select at least one category"); if (!categories.length) return toast.error("Mindestens eine Kategorie auswählen");
setStage("running"); setStage("running");
setResults([]); setResults([]);
setProgress({ current: 0, total: numResults, phase: "Searching Google..." }); setProgress({ current: 0, total: numResults, phase: "Google wird durchsucht..." });
try { try {
const res = await fetch("/api/jobs/serp-enrich", { const res = await fetch("/api/jobs/serp-enrich", {
@@ -84,7 +84,7 @@ export default function SerpPage() {
}; };
const pollJob = (id: string) => { const pollJob = (id: string) => {
let phase = "Searching Google..."; let phase = "Google wird durchsucht...";
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const res = await fetch(`/api/jobs/${id}/status`); const res = await fetch(`/api/jobs/${id}/status`);
@@ -94,8 +94,8 @@ export default function SerpPage() {
}; };
// Infer phase from progress // Infer phase from progress
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Enriching domains with Anymailfinder..."; if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Domains werden mit Anymailfinder angereichert...";
if (data.emailsFound > 0) phase = `Found ${data.emailsFound} emails so far...`; if (data.emailsFound > 0) phase = `Bisher ${data.emailsFound} E-Mails gefunden...`;
setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, phase }); setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, phase });
if (data.results?.length) setResults(data.results); if (data.results?.length) setResults(data.results);
@@ -108,10 +108,10 @@ export default function SerpPage() {
setResults(data.results || []); setResults(data.results || []);
if (data.status === "complete") { if (data.status === "complete") {
setStage("done"); setStage("done");
toast.success(`Done! Found ${data.emailsFound} emails`); toast.success(`Fertig! ${data.emailsFound} E-Mails gefunden`);
} else { } else {
setStage("failed"); setStage("failed");
toast.error("Job failed. Check your API keys in Settings."); toast.error("Job fehlgeschlagen. Bitte API-Keys in den Einstellungen prüfen.");
} }
} }
} catch { } catch {
@@ -148,9 +148,9 @@ export default function SerpPage() {
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
<span>Google SERP</span> <span>Google SERP</span>
</div> </div>
<h1 className="text-2xl font-bold text-white">SERP Email Enrichment</h1> <h1 className="text-2xl font-bold text-white">SERP E-Mail Anreicherung</h1>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
Scrape Google search results via Apify, extract domains, then find decision maker emails. Scrape Google-Suchergebnisse über Apify, extrahiere Domains und finde Entscheider-E-Mails.
</p> </p>
</div> </div>
</div> </div>
@@ -159,11 +159,11 @@ export default function SerpPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span> <span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span>
Search Configuration Suchkonfiguration
</h2> </h2>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Search Term</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Suchbegriff</Label>
<Input <Input
placeholder='e.g. "Solaranlage Installateur Deutschland"' placeholder='e.g. "Solaranlage Installateur Deutschland"'
value={query} value={query}
@@ -174,32 +174,32 @@ export default function SerpPage() {
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Number of Results</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Anzahl Ergebnisse</Label>
<Select value={String(numResults)} onValueChange={v => setNumResults(Number(v))}> <Select value={String(numResults)} onValueChange={v => setNumResults(Number(v))}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white"> <SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent className="bg-[#111118] border-[#2e2e3e]">
{RESULT_OPTIONS.map(n => ( {RESULT_OPTIONS.map(n => (
<SelectItem key={n} value={String(n)} className="text-gray-300">{n} results</SelectItem> <SelectItem key={n} value={String(n)} className="text-gray-300">{n} Ergebnisse</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-gray-500 mt-1">~{maxPages} page{maxPages > 1 ? "s" : ""} of Google</p> <p className="text-xs text-gray-500 mt-1">~{maxPages} Google-Seite{maxPages > 1 ? "n" : ""}</p>
</div> </div>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Country</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Land</Label>
<Select value={country} onValueChange={v => setCountry(v ?? "de")}> <Select value={country} onValueChange={v => setCountry(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white"> <SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[ {[
{ value: "de", label: "Germany (DE)" }, { value: "de", label: "Deutschland (DE)" },
{ value: "at", label: "Austria (AT)" }, { value: "at", label: "Österreich (AT)" },
{ value: "ch", label: "Switzerland (CH)" }, { value: "ch", label: "Schweiz (CH)" },
{ value: "us", label: "United States (US)" }, { value: "us", label: "USA (US)" },
{ value: "gb", label: "United Kingdom (GB)" }, { value: "gb", label: "Großbritannien (GB)" },
].map(c => ( ].map(c => (
<SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem> <SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem>
))} ))}
@@ -207,16 +207,16 @@ export default function SerpPage() {
</Select> </Select>
</div> </div>
<div> <div>
<Label className="text-gray-300 text-sm mb-1.5 block">Language</Label> <Label className="text-gray-300 text-sm mb-1.5 block">Sprache</Label>
<Select value={language} onValueChange={v => setLanguage(v ?? "de")}> <Select value={language} onValueChange={v => setLanguage(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white"> <SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[ {[
{ value: "de", label: "German" }, { value: "de", label: "Deutsch" },
{ value: "en", label: "English" }, { value: "en", label: "Englisch" },
{ value: "fr", label: "French" }, { value: "fr", label: "Französisch" },
].map(l => ( ].map(l => (
<SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem> <SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem>
))} ))}
@@ -234,10 +234,10 @@ export default function SerpPage() {
/> />
<div> <div>
<label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer"> <label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer">
Exclude social media, directories & aggregators Soziale Medien, Verzeichnisse & Aggregatoren ausschließen
</label> </label>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
Filters out: LinkedIn, Facebook, Instagram, Yelp, Wikipedia, Xing, Twitter/X, YouTube, Google Maps Filtert: LinkedIn, Facebook, Instagram, Yelp, Wikipedia, Xing, Twitter/X, YouTube, Google Maps
</p> </p>
</div> </div>
</div> </div>
@@ -247,7 +247,7 @@ export default function SerpPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span> <span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories Entscheider-Kategorien
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => ( {CATEGORY_OPTIONS.map(opt => (
@@ -272,7 +272,7 @@ export default function SerpPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<h2 className="text-base font-semibold text-white flex items-center gap-2"> <h2 className="text-base font-semibold text-white flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span> <span className="w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span>
Run Pipeline Pipeline starten
</h2> </h2>
{stage === "idle" || stage === "failed" ? ( {stage === "idle" || stage === "failed" ? (
@@ -281,11 +281,11 @@ export default function SerpPage() {
disabled={!query.trim() || !categories.length} disabled={!query.trim() || !categories.length}
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-purple-500/25 transition-all" className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-8 shadow-lg hover:shadow-purple-500/25 transition-all"
> >
Start SERP Search SERP-Suche starten
</Button> </Button>
) : stage === "running" ? ( ) : stage === "running" ? (
<ProgressCard <ProgressCard
title="Running SERP pipeline..." title="SERP-Pipeline läuft..."
current={progress.current} current={progress.current}
total={progress.total} total={progress.total}
subtitle={progress.phase} subtitle={progress.phase}
@@ -293,10 +293,10 @@ export default function SerpPage() {
/> />
) : ( ) : (
<ProgressCard <ProgressCard
title="Pipeline complete" title="Pipeline abgeschlossen"
current={emailsFound} current={emailsFound}
total={results.length} total={results.length}
subtitle={`Hit rate: ${hitRate}% · ${results.length} domains scraped`} subtitle={`Trefferquote: ${hitRate}% · ${results.length} Domains`}
status="complete" status="complete"
/> />
)} )}
@@ -306,11 +306,11 @@ export default function SerpPage() {
{results.length > 0 && ( {results.length > 0 && (
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4"> <Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-semibold text-white">Results</h2> <h2 className="text-base font-semibold text-white">Ergebnisse</h2>
<ExportButtons <ExportButtons
rows={exportRows} rows={exportRows}
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`} filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found • ${hitRate}% hit rate`} summary={`${emailsFound} E-Mails gefunden${hitRate}% Trefferquote`}
/> />
</div> </div>
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} /> <ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
@@ -320,8 +320,8 @@ export default function SerpPage() {
{stage === "idle" && ( {stage === "idle" && (
<EmptyState <EmptyState
icon={Search} icon={Search}
title="Configure and run a SERP search" title="SERP-Suche konfigurieren und starten"
description="Enter a search term, select your target categories, and hit Start to find leads from Google search results." description="Suchbegriff eingeben, Zielkategorien auswählen und Starten drücken, um Leads aus Google-Suchergebnissen zu finden."
/> />
)} )}
</div> </div>

View File

@@ -104,7 +104,7 @@ export default function SettingsPage() {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) throw new Error("Save failed"); if (!res.ok) throw new Error("Save failed");
toast.success("Credentials saved successfully"); toast.success("Zugangsdaten erfolgreich gespeichert");
setCreds(prev => { setCreds(prev => {
const next = { ...prev }; const next = { ...prev };
for (const service of Object.keys(body)) { for (const service of Object.keys(body)) {
@@ -113,7 +113,7 @@ export default function SettingsPage() {
return next; return next;
}); });
} catch { } catch {
toast.error("Failed to save credentials"); toast.error("Zugangsdaten konnten nicht gespeichert werden");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -126,11 +126,11 @@ export default function SettingsPage() {
const res = await fetch(`/api/credentials/test?service=${service}`); const res = await fetch(`/api/credentials/test?service=${service}`);
const data = await res.json() as { ok: boolean }; const data = await res.json() as { ok: boolean };
update(service, "testResult", data.ok ? "ok" : "fail"); update(service, "testResult", data.ok ? "ok" : "fail");
if (data.ok) toast.success(`${service} connection verified`); if (data.ok) toast.success(`${service}-Verbindung erfolgreich`);
else toast.error(`${service} connection failed — check your key`); else toast.error(`${service}-Verbindung fehlgeschlagen — API-Key prüfen`);
} catch { } catch {
update(service, "testResult", "fail"); update(service, "testResult", "fail");
toast.error("Connection test failed"); toast.error("Verbindungstest fehlgeschlagen");
} finally { } finally {
update(service, "testing", false); update(service, "testing", false);
} }
@@ -140,9 +140,9 @@ export default function SettingsPage() {
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-white">Settings</h1> <h1 className="text-2xl font-bold text-white">Einstellungen</h1>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
API credentials are encrypted with AES-256 and stored locally in SQLite. API-Zugangsdaten werden mit AES-256 verschlüsselt und lokal in SQLite gespeichert.
</p> </p>
</div> </div>
@@ -158,11 +158,11 @@ export default function SettingsPage() {
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
{state.saved ? ( {state.saved ? (
<span className="flex items-center gap-1 text-xs text-green-400"> <span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Configured <CheckCircle2 className="w-3.5 h-3.5" /> Konfiguriert
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1 text-xs text-red-400"> <span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Not configured <XCircle className="w-3.5 h-3.5" /> Nicht konfiguriert
</span> </span>
)} )}
</div> </div>
@@ -221,17 +221,17 @@ export default function SettingsPage() {
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300" className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
> >
{state.testing ? ( {state.testing ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</> <><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Teste...</>
) : "Test Connection"} ) : "Verbindung testen"}
</Button> </Button>
{state.testResult === "ok" && ( {state.testResult === "ok" && (
<span className="flex items-center gap-1 text-xs text-green-400"> <span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Connected <CheckCircle2 className="w-3.5 h-3.5" /> Verbunden
</span> </span>
)} )}
{state.testResult === "fail" && ( {state.testResult === "fail" && (
<span className="flex items-center gap-1 text-xs text-red-400"> <span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Failed <XCircle className="w-3.5 h-3.5" /> Fehlgeschlagen
</span> </span>
)} )}
</div> </div>
@@ -245,7 +245,7 @@ export default function SettingsPage() {
onClick={saveAll} onClick={saveAll}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all" className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
> >
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save All Credentials"} {saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Speichern...</> : "Alle Zugangsdaten speichern"}
</Button> </Button>
</div> </div>
); );

View File

@@ -77,7 +77,7 @@ export function Sidebar() {
{/* Credential status */} {/* Credential status */}
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="px-4 py-4 border-t border-[#1e1e2e] space-y-2"> <div className="px-4 py-4 border-t border-[#1e1e2e] space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">API Status</p> <p className="text-xs text-gray-500 uppercase tracking-wider mb-3">API-Status</p>
{[ {[
{ key: "anymailfinder", label: "Anymailfinder" }, { key: "anymailfinder", label: "Anymailfinder" },
{ key: "apify", label: "Apify" }, { key: "apify", label: "Apify" },

View File

@@ -5,12 +5,12 @@ import { useAppStore } from "@/lib/store";
import { Activity } from "lucide-react"; import { Activity } from "lucide-react";
const BREADCRUMBS: Record<string, string> = { const BREADCRUMBS: Record<string, string> = {
"/airscale": "AirScale → Email", "/airscale": "AirScale → E-Mail",
"/linkedin": "LinkedIn → Email", "/linkedin": "LinkedIn → E-Mail",
"/serp": "SERP → Email", "/serp": "SERP → E-Mail",
"/maps": "Google Maps → Email", "/maps": "Google Maps → E-Mail",
"/results": "Results & History", "/results": "Ergebnisse & Verlauf",
"/settings": "Settings", "/settings": "Einstellungen",
}; };
export function TopBar() { export function TopBar() {

View File

@@ -22,7 +22,7 @@ export function ExportButtons({ rows, filename, disabled, summary }: ExportButto
onClick={() => exportToCSV(rows, `${filename}.csv`)} onClick={() => exportToCSV(rows, `${filename}.csv`)}
className="border-[#2e2e3e] hover:border-blue-500/50 hover:bg-blue-500/5 text-gray-300" 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 <Download className="w-4 h-4 mr-1.5" /> CSV herunterladen
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -31,7 +31,7 @@ export function ExportButtons({ rows, filename, disabled, summary }: ExportButto
onClick={() => exportToExcel(rows, `${filename}.xlsx`)} onClick={() => exportToExcel(rows, `${filename}.xlsx`)}
className="border-[#2e2e3e] hover:border-purple-500/50 hover:bg-purple-500/5 text-gray-300" 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 <FileSpreadsheet className="w-4 h-4 mr-1.5" /> Excel herunterladen
</Button> </Button>
</div> </div>
); );

View File

@@ -54,13 +54,13 @@ export function FileDropZone({ onFile, accept = ".csv", label = "Drop your CSV f
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<FileText className="w-8 h-8 text-green-400" /> <FileText className="w-8 h-8 text-green-400" />
<span className="text-sm text-green-400 font-medium">{filename}</span> <span className="text-sm text-green-400 font-medium">{filename}</span>
<span className="text-xs text-gray-500">Click to replace</span> <span className="text-xs text-gray-500">Klicken zum Ersetzen</span>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<Upload className="w-8 h-8 text-gray-500" /> <Upload className="w-8 h-8 text-gray-500" />
<span className="text-sm text-gray-300">{label}</span> <span className="text-sm text-gray-300">{label}</span>
<span className="text-xs text-gray-500">Click to browse or drag & drop</span> <span className="text-xs text-gray-500">Klicken oder Datei hier ablegen</span>
</div> </div>
)} )}
</label> </label>

View File

@@ -48,11 +48,11 @@ export function ProgressCard({ title, current, total, subtitle, status = "runnin
export function StatusBadge({ status }: { status: string }) { export function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; color: string; dot: 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" }, running: { label: "Läuft", 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" }, complete: { label: "Abgeschlossen", 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" }, failed: { label: "Fehlgeschlagen", 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" }, pending: { label: "Ausstehend", 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" }, idle: { label: "Bereit", color: "bg-gray-500/10 text-gray-400 border-gray-500/20", dot: "bg-gray-400" },
}; };
const c = config[status] || config.idle; const c = config[status] || config.idle;
return ( return (

View File

@@ -81,12 +81,12 @@ export function ResultsTable({ rows, loading, selectable, onSelectionChange, ext
/> />
</th> </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">Unternehmen</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">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">Kontakt</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">Position</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">E-Mail</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Confidence</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Konfidenz</th>
{extraColumns?.map(col => ( {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> <th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{col.label}</th>
))} ))}