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 startEnrichment = async () => {
if (!companies.length) return toast.error("No companies with domains found");
if (!categories.length) return toast.error("Select at least one decision maker category");
if (!companies.length) return toast.error("Keine Unternehmen mit Domains gefunden");
if (!categories.length) return toast.error("Mindestens eine Entscheider-Kategorie auswählen");
setRunning(true);
setResults([]);
@@ -91,7 +91,7 @@ export default function AirScalePage() {
setJobId(data.jobId);
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
toast.success("Enrichment started!");
toast.success("Anreicherung gestartet!");
pollJob(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start");
@@ -119,9 +119,9 @@ export default function AirScalePage() {
setRunning(false);
removeJob(id);
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 {
toast.error(`Job failed: ${data.error || "Unknown error"}`);
toast.error(`Job fehlgeschlagen: ${data.error || "Unbekannter Fehler"}`);
}
}
} catch {
@@ -159,9 +159,9 @@ export default function AirScalePage() {
<ChevronRight className="w-3 h-3" />
<span>AirScale Companies</span>
</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">
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>
</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>
Upload AirScale CSV
</h2>
<FileDropZone onFile={onFile} label="Drop your AirScale CSV export here" />
<FileDropZone onFile={onFile} label="AirScale CSV-Export hier ablegen" />
{csvData.length > 0 && (
<div className="space-y-4">
{/* Stats */}
<div className="flex gap-4">
{[
{ label: "Total rows", value: csvData.length, color: "text-white" },
{ label: "With domains", value: withDomain, color: "text-green-400" },
{ label: "Missing domains", value: withoutDomain, color: "text-yellow-400" },
{ label: "Zeilen gesamt", value: csvData.length, color: "text-white" },
{ label: "Mit Domain", value: withDomain, color: "text-green-400" },
{ label: "Ohne Domain", value: withoutDomain, color: "text-yellow-400" },
].map(stat => (
<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>
@@ -193,10 +193,10 @@ export default function AirScalePage() {
{/* Column mapper */}
<div className="grid grid-cols-2 gap-4">
<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 ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select domain column..." />
<SelectValue placeholder="Domain-Spalte auswählen..." />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{headers.map(h => (
@@ -206,13 +206,13 @@ export default function AirScalePage() {
</Select>
</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 ?? "")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue placeholder="Select name column..." />
<SelectValue placeholder="Namensspalte auswählen..." />
</SelectTrigger>
<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 => (
<SelectItem key={h} value={h} className="text-gray-300">{h}</SelectItem>
))}
@@ -224,7 +224,7 @@ export default function AirScalePage() {
{/* Preview */}
<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]">
Preview (first 5 rows)
Vorschau (erste 5 Zeilen)
</div>
<div className="overflow-x-auto">
<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">
<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>
Decision Maker Categories
Entscheider-Kategorien
</h2>
<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">
{CATEGORY_OPTIONS.map(opt => (
<button
@@ -284,7 +284,7 @@ export default function AirScalePage() {
))}
</div>
<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>
</div>
</Card>
@@ -293,15 +293,15 @@ export default function AirScalePage() {
<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>
Run Enrichment
Anreicherung starten
</h2>
{!running && jobStatus === "idle" && (
csvData.length === 0 ? (
<EmptyState
icon={Building2}
title="Upload a CSV to get started"
description="Upload your AirScale export above, then configure and run enrichment."
title="CSV hochladen um zu starten"
description="Lade deinen AirScale-Export oben hoch, konfiguriere und starte die Anreicherung."
/>
) : (
<Button
@@ -309,27 +309,27 @@ export default function AirScalePage() {
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"
>
Start Enrichment ({withDomain} companies)
Anreicherung starten ({withDomain} Unternehmen)
</Button>
)
)}
{(running || jobStatus === "running") && (
<ProgressCard
title="Enriching companies..."
title="Unternehmen werden angereichert..."
current={progress.current}
total={progress.total || withDomain}
subtitle="Finding decision maker emails via Anymailfinder"
subtitle="Entscheider-E-Mails werden über Anymailfinder gesucht"
status="running"
/>
)}
{jobStatus === "complete" && (
<ProgressCard
title="Enrichment complete"
title="Anreicherung abgeschlossen"
current={progress.current}
total={progress.total}
subtitle={`Hit rate: ${hitRate}%`}
subtitle={`Trefferquote: ${hitRate}%`}
status="complete"
/>
)}
@@ -337,7 +337,7 @@ export default function AirScalePage() {
{jobStatus === "failed" && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<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>
)}
</Card>
@@ -348,12 +348,12 @@ export default function AirScalePage() {
<div className="flex items-center justify-between flex-wrap gap-3">
<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>
Results
Ergebnisse
</h2>
<ExportButtons
rows={exportRows}
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>
<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 startScrape = async () => {
if (!urlValid) return toast.error("Please paste a valid Sales Navigator URL");
if (!vayneConfigured) return toast.error("Configure your Vayne API token in Settings first");
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 });
@@ -79,7 +79,7 @@ export default function LinkedInPage() {
addJob({ id: data.jobId, type: "linkedin-scrape", status: "running", progress: 0, total: maxResults });
pollScrape(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start scrape");
toast.error(err instanceof Error ? err.message : "Scraping konnte nicht gestartet werden");
setStage("failed");
}
};
@@ -104,10 +104,10 @@ export default function LinkedInPage() {
setStage("scraped");
setResults(data.results || []);
setSelectedIds(data.results?.map(r => r.id) || []);
toast.success(`Scraped ${data.totalLeads} profiles from LinkedIn`);
toast.success(`${data.totalLeads} Profile von LinkedIn gescrapt`);
} else {
setStage("failed");
toast.error("Scrape failed. Check Vayne token in Settings.");
toast.error("Scraping fehlgeschlagen. Bitte Vayne-Token in den Einstellungen prüfen.");
}
}
} catch {
@@ -118,8 +118,8 @@ export default function LinkedInPage() {
};
const startEnrich = async () => {
if (!selectedIds.length) return toast.error("Select at least one profile to enrich");
if (!categories.length) return toast.error("Select at least one category");
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 });
@@ -137,7 +137,7 @@ export default function LinkedInPage() {
addJob({ id: data.jobId, type: "linkedin-enrich", status: "running", progress: 0, total: selectedIds.length });
pollEnrich(data.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to start enrichment");
toast.error(err instanceof Error ? err.message : "Anreicherung konnte nicht gestartet werden");
setStage("scraped");
}
};
@@ -163,10 +163,10 @@ export default function LinkedInPage() {
if (data.status === "complete") {
setStage("done");
toast.success(`Found ${data.emailsFound} emails`);
toast.success(`${data.emailsFound} E-Mails gefunden`);
} else {
setStage("scraped");
toast.error("Enrichment failed");
toast.error("Anreicherung fehlgeschlagen");
}
}
} catch {
@@ -201,9 +201,9 @@ export default function LinkedInPage() {
<ChevronRight className="w-3 h-3" />
<span>LinkedIn Sales Navigator</span>
</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">
Scrape Sales Navigator profiles via Vayne, then enrich with Anymailfinder.
Scrape Sales Navigator-Profile über Vayne und reichere sie mit Anymailfinder an.
</p>
</div>
</div>
@@ -216,7 +216,7 @@ export default function LinkedInPage() {
>
<div className="flex items-center gap-2 text-purple-300 font-medium">
<Info className="w-4 h-4" />
Recommended Sales Navigator Filter Settings
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>
@@ -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="grid grid-cols-2 gap-4 pt-4">
<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">
{["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>
@@ -232,7 +232,7 @@ export default function LinkedInPage() {
</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">
{["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>
@@ -240,11 +240,11 @@ export default function LinkedInPage() {
</div>
</div>
<div>
<p className="text-gray-400 font-medium mb-2">Country</p>
<span className="bg-green-500/10 text-green-300 px-2 py-0.5 rounded text-xs">Germany (Deutschland)</span>
<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">Target Titles</p>
<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>
@@ -269,16 +269,16 @@ export default function LinkedInPage() {
: 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 ? "Checking Vayne configuration..."
{vayneConfigured === null ? "Vayne-Konfiguration wird geprüft..."
: 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>
<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
placeholder="https://www.linkedin.com/sales/search/people?query=..."
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"
/>
{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>
<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
type="number"
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">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-yellow-300">
Do not close this tab while scraping is in progress. The job runs in the background
but progress tracking requires this tab to remain open.
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>
@@ -316,17 +316,17 @@ export default function LinkedInPage() {
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"
>
Start LinkedIn Scrape
LinkedIn-Scraping starten
</Button>
</Card>
{/* Scrape progress */}
{(stage === "scraping") && (
<ProgressCard
title="Scraping LinkedIn profiles via Vayne..."
title="LinkedIn-Profile werden über Vayne gescrapt..."
current={scrapeProgress.current}
total={scrapeProgress.total}
subtitle="Creating order → Scraping → Generating export..."
subtitle="Auftrag erstellen → Scraping → Export generieren..."
status="running"
/>
)}
@@ -336,9 +336,9 @@ export default function LinkedInPage() {
<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>
Scraped Profiles ({results.length})
Gescrapte Profile ({results.length})
</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
rows={results}
selectable
@@ -352,7 +352,7 @@ export default function LinkedInPage() {
<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>
Enrich with Emails
Mit E-Mails anreichern
</h2>
<div className="flex flex-wrap gap-2">
@@ -375,10 +375,10 @@ export default function LinkedInPage() {
{stage === "enriching" && (
<ProgressCard
title="Enriching profiles..."
title="Profile werden angereichert..."
current={enrichProgress.current}
total={enrichProgress.total}
subtitle="Finding emails via Anymailfinder"
subtitle="E-Mails werden über Anymailfinder gesucht"
status="running"
/>
)}
@@ -389,7 +389,7 @@ export default function LinkedInPage() {
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"
>
Enrich {selectedIds.length} Selected Profiles with Emails
{selectedIds.length} ausgewählte Profile mit E-Mails anreichern
</Button>
)}
</Card>
@@ -401,7 +401,7 @@ export default function LinkedInPage() {
<ExportButtons
rows={exportRows}
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>
)}
@@ -410,8 +410,8 @@ export default function LinkedInPage() {
{stage === "idle" && (
<EmptyState
icon={Linkedin}
title="Start by pasting a Sales Navigator URL"
description="Configure your search filters in Sales Navigator, copy the URL, and paste it above to begin scraping."
title="Sales Navigator URL einfügen"
description="Suchfilter in Sales Navigator konfigurieren, URL kopieren und oben einfügen."
/>
)}
</div>

View File

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

View File

@@ -51,12 +51,12 @@ export default function SerpPage() {
const maxPages = Math.max(1, Math.ceil(numResults / 10));
const startJob = async () => {
if (!query.trim()) return toast.error("Enter a search query");
if (!categories.length) return toast.error("Select at least one category");
if (!query.trim()) return toast.error("Suchbegriff eingeben");
if (!categories.length) return toast.error("Mindestens eine Kategorie auswählen");
setStage("running");
setResults([]);
setProgress({ current: 0, total: numResults, phase: "Searching Google..." });
setProgress({ current: 0, total: numResults, phase: "Google wird durchsucht..." });
try {
const res = await fetch("/api/jobs/serp-enrich", {
@@ -84,7 +84,7 @@ export default function SerpPage() {
};
const pollJob = (id: string) => {
let phase = "Searching Google...";
let phase = "Google wird durchsucht...";
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/jobs/${id}/status`);
@@ -94,8 +94,8 @@ export default function SerpPage() {
};
// Infer phase from progress
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Enriching domains with Anymailfinder...";
if (data.emailsFound > 0) phase = `Found ${data.emailsFound} emails so far...`;
if (data.totalLeads > 0 && data.emailsFound === 0) phase = "Domains werden mit Anymailfinder angereichert...";
if (data.emailsFound > 0) phase = `Bisher ${data.emailsFound} E-Mails gefunden...`;
setProgress({ current: data.emailsFound, total: data.totalLeads || numResults, phase });
if (data.results?.length) setResults(data.results);
@@ -108,10 +108,10 @@ export default function SerpPage() {
setResults(data.results || []);
if (data.status === "complete") {
setStage("done");
toast.success(`Done! Found ${data.emailsFound} emails`);
toast.success(`Fertig! ${data.emailsFound} E-Mails gefunden`);
} else {
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 {
@@ -148,9 +148,9 @@ export default function SerpPage() {
<ChevronRight className="w-3 h-3" />
<span>Google SERP</span>
</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">
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>
</div>
</div>
@@ -159,11 +159,11 @@ export default function SerpPage() {
<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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">1</span>
Search Configuration
Suchkonfiguration
</h2>
<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
placeholder='e.g. "Solaranlage Installateur Deutschland"'
value={query}
@@ -174,32 +174,32 @@ export default function SerpPage() {
<div className="grid grid-cols-3 gap-4">
<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))}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{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>
</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>
<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")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "Germany (DE)" },
{ value: "at", label: "Austria (AT)" },
{ value: "ch", label: "Switzerland (CH)" },
{ value: "us", label: "United States (US)" },
{ value: "gb", label: "United Kingdom (GB)" },
{ value: "de", label: "Deutschland (DE)" },
{ value: "at", label: "Österreich (AT)" },
{ value: "ch", label: "Schweiz (CH)" },
{ value: "us", label: "USA (US)" },
{ value: "gb", label: "Großbritannien (GB)" },
].map(c => (
<SelectItem key={c.value} value={c.value} className="text-gray-300">{c.label}</SelectItem>
))}
@@ -207,16 +207,16 @@ export default function SerpPage() {
</Select>
</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")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[
{ value: "de", label: "German" },
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
{ value: "de", label: "Deutsch" },
{ value: "en", label: "Englisch" },
{ value: "fr", label: "Französisch" },
].map(l => (
<SelectItem key={l.value} value={l.value} className="text-gray-300">{l.label}</SelectItem>
))}
@@ -234,10 +234,10 @@ export default function SerpPage() {
/>
<div>
<label htmlFor="filterSocial" className="text-sm text-gray-300 cursor-pointer">
Exclude social media, directories & aggregators
Soziale Medien, Verzeichnisse & Aggregatoren ausschließen
</label>
<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>
</div>
</div>
@@ -247,7 +247,7 @@ export default function SerpPage() {
<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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">2</span>
Decision Maker Categories
Entscheider-Kategorien
</h2>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(opt => (
@@ -272,7 +272,7 @@ export default function SerpPage() {
<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-purple-500/20 text-purple-400 text-xs flex items-center justify-center font-bold">3</span>
Run Pipeline
Pipeline starten
</h2>
{stage === "idle" || stage === "failed" ? (
@@ -281,11 +281,11 @@ export default function SerpPage() {
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"
>
Start SERP Search
SERP-Suche starten
</Button>
) : stage === "running" ? (
<ProgressCard
title="Running SERP pipeline..."
title="SERP-Pipeline läuft..."
current={progress.current}
total={progress.total}
subtitle={progress.phase}
@@ -293,10 +293,10 @@ export default function SerpPage() {
/>
) : (
<ProgressCard
title="Pipeline complete"
title="Pipeline abgeschlossen"
current={emailsFound}
total={results.length}
subtitle={`Hit rate: ${hitRate}% · ${results.length} domains scraped`}
subtitle={`Trefferquote: ${hitRate}% · ${results.length} Domains`}
status="complete"
/>
)}
@@ -306,11 +306,11 @@ export default function SerpPage() {
{results.length > 0 && (
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<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
rows={exportRows}
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
summary={`${emailsFound} emails found • ${hitRate}% hit rate`}
summary={`${emailsFound} E-Mails gefunden${hitRate}% Trefferquote`}
/>
</div>
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
@@ -320,8 +320,8 @@ export default function SerpPage() {
{stage === "idle" && (
<EmptyState
icon={Search}
title="Configure and run a SERP search"
description="Enter a search term, select your target categories, and hit Start to find leads from Google search results."
title="SERP-Suche konfigurieren und starten"
description="Suchbegriff eingeben, Zielkategorien auswählen und Starten drücken, um Leads aus Google-Suchergebnissen zu finden."
/>
)}
</div>

View File

@@ -104,7 +104,7 @@ export default function SettingsPage() {
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Save failed");
toast.success("Credentials saved successfully");
toast.success("Zugangsdaten erfolgreich gespeichert");
setCreds(prev => {
const next = { ...prev };
for (const service of Object.keys(body)) {
@@ -113,7 +113,7 @@ export default function SettingsPage() {
return next;
});
} catch {
toast.error("Failed to save credentials");
toast.error("Zugangsdaten konnten nicht gespeichert werden");
} finally {
setSaving(false);
}
@@ -126,11 +126,11 @@ export default function SettingsPage() {
const res = await fetch(`/api/credentials/test?service=${service}`);
const data = await res.json() as { ok: boolean };
update(service, "testResult", data.ok ? "ok" : "fail");
if (data.ok) toast.success(`${service} connection verified`);
else toast.error(`${service} connection failed — check your key`);
if (data.ok) toast.success(`${service}-Verbindung erfolgreich`);
else toast.error(`${service}-Verbindung fehlgeschlagen — API-Key prüfen`);
} catch {
update(service, "testResult", "fail");
toast.error("Connection test failed");
toast.error("Verbindungstest fehlgeschlagen");
} finally {
update(service, "testing", false);
}
@@ -140,9 +140,9 @@ export default function SettingsPage() {
<div className="max-w-2xl space-y-6">
{/* Header */}
<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">
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>
</div>
@@ -158,11 +158,11 @@ export default function SettingsPage() {
<div className="flex items-center gap-2 mt-1">
{state.saved ? (
<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 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>
)}
</div>
@@ -221,17 +221,17 @@ export default function SettingsPage() {
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
>
{state.testing ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</>
) : "Test Connection"}
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Teste...</>
) : "Verbindung testen"}
</Button>
{state.testResult === "ok" && (
<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>
)}
{state.testResult === "fail" && (
<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>
)}
</div>
@@ -245,7 +245,7 @@ export default function SettingsPage() {
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"
>
{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>
</div>
);