feat: OnyvaLeads customer UI — Suche + Leadspeicher

- New Topbar: logo, 2-tab pill switcher, live "Neu" badge
- /suche page: SearchCard, LoadingCard, ExamplePills
- /leadspeicher page: full leads table with filters, pagination
- StatusBadge, StatusPopover, LeadSidePanel, BulkActionBar
- POST /api/search: unified search entry point → serp-enrich
- Remove Sidebar + old TopBar from layout
- Title: OnyvaLeads, redirect / → /suche

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-03-27 16:48:05 +01:00
parent 47b78fa749
commit 60073b97c9
15 changed files with 2235 additions and 44 deletions

40
app/api/search/route.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { query: string; region: string; count: number };
const { query, region, count } = body;
if (!query || typeof query !== "string") {
return NextResponse.json({ error: "query is required" }, { status: 400 });
}
const searchQuery = region ? `${query} ${region}` : query;
const baseUrl = req.nextUrl.origin;
const res = await fetch(`${baseUrl}/api/jobs/serp-enrich`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: searchQuery,
maxPages: Math.ceil((count || 50) / 10),
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
return NextResponse.json({ error: err.error || "Failed to start job" }, { status: 500 });
}
const data = await res.json() as { jobId: string };
return NextResponse.json({ jobId: data.jobId });
} catch (err) {
console.error("POST /api/search error:", err);
return NextResponse.json({ error: "Failed to start search" }, { status: 500 });
}
}

View File

@@ -1,29 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { TopBar } from "@/components/layout/TopBar";
import { Topbar } from "@/components/layout/Topbar";
import { Toaster } from "@/components/ui/sonner";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "OnyvaLeads — Lead Generation Platform",
description: "Unified lead generation and email enrichment platform",
title: "OnyvaLeads",
description: "Leads finden und verwalten",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${inter.variable} antialiased`}>
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
<html lang="de" className="dark">
<body className="antialiased">
<div className="flex flex-col min-h-screen" style={{ background: "#0a0a0f" }}>
<Topbar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
<Toaster position="bottom-right" theme="dark" />
</body>

335
app/leadspeicher/page.tsx Normal file
View File

@@ -0,0 +1,335 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { LeadsToolbar } from "@/components/leadspeicher/LeadsToolbar";
import { LeadsTable } from "@/components/leadspeicher/LeadsTable";
import type { Lead } from "@/components/leadspeicher/LeadsTable";
import { BulkActionBar } from "@/components/leadspeicher/BulkActionBar";
interface LeadsResponse {
leads: Lead[];
total: number;
page: number;
pages: number;
perPage: number;
}
interface StatsResponse {
total: number;
new: number;
withEmail: number;
}
export default function LeadspeicherPage() {
const router = useRouter();
const [leads, setLeads] = useState<Lead[]>([]);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [stats, setStats] = useState<StatsResponse>({ total: 0, new: 0, withEmail: 0 });
const fetchRef = useRef(0);
const fetchLeads = useCallback(async () => {
const id = ++fetchRef.current;
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(perPage),
});
if (search) params.set("search", search);
if (status) params.append("status", status);
const res = await fetch(`/api/leads?${params.toString()}`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as LeadsResponse;
if (id === fetchRef.current) {
setLeads(data.leads);
setTotal(data.total);
setPages(data.pages);
}
} catch {
// silent
} finally {
if (id === fetchRef.current) setLoading(false);
}
}, [page, perPage, search, status]);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/leads/stats");
const data = await res.json() as StatsResponse;
setStats(data);
} catch {
// silent
}
}, []);
useEffect(() => {
fetchLeads();
fetchStats();
}, [fetchLeads, fetchStats]);
function handleLeadUpdate(id: string, updates: Partial<Lead>) {
setLeads((prev) =>
prev.map((l) => (l.id === id ? { ...l, ...updates } : l))
);
}
function handleToggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleToggleAll() {
if (selected.size === leads.length && leads.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(leads.map((l) => l.id)));
}
}
async function handleBulkStatus(newStatus: string) {
const ids = Array.from(selected);
// optimistic
setLeads((prev) =>
prev.map((l) => (selected.has(l.id) ? { ...l, status: newStatus } : l))
);
setSelected(new Set());
try {
await fetch("/api/leads/bulk", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, status: newStatus }),
});
} catch {
toast.error("Status-Update fehlgeschlagen");
fetchLeads();
}
}
async function handleBulkDelete() {
if (!confirm(`${selected.size} Lead(s) wirklich löschen?`)) return;
const ids = Array.from(selected);
setLeads((prev) => prev.filter((l) => !selected.has(l.id)));
setSelected(new Set());
try {
await Promise.all(
ids.map((id) =>
fetch(`/api/leads/${id}`, { method: "DELETE" })
)
);
fetchStats();
} catch {
toast.error("Löschen fehlgeschlagen");
fetchLeads();
}
}
const hasFilter = search !== "" || status !== "";
const withEmailCount = stats.withEmail;
const withEmailPct = stats.total > 0 ? Math.round((withEmailCount / stats.total) * 100) : 0;
const exportParams: Record<string, string> = {};
if (search) exportParams.search = search;
if (status) exportParams.status = status;
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 52px)" }}>
{/* Toolbar */}
<LeadsToolbar
search={search}
status={status}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
onStatusChange={(v) => { setStatus(v); setPage(1); }}
exportParams={exportParams}
/>
{/* Table area */}
<div style={{ flex: 1 }}>
{loading && leads.length === 0 ? (
// Loading skeleton
<div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280", fontSize: 13 }}>
Wird geladen
</div>
) : leads.length === 0 ? (
// Empty state
hasFilter ? (
<div style={{ padding: "60px 20px", textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 8 }}>
Keine Leads gefunden
</div>
<div style={{ fontSize: 13, color: "#9ca3af", marginBottom: 20 }}>
Versuche andere Filtereinstellungen.
</div>
<button
onClick={() => { setSearch(""); setStatus(""); setPage(1); }}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 8,
padding: "8px 16px",
fontSize: 13,
color: "#3b82f6",
cursor: "pointer",
}}
>
Filter zurücksetzen
</button>
</div>
) : (
<div style={{ padding: "60px 20px", textAlign: "center" }}>
<div style={{ marginBottom: 16 }}>
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ margin: "0 auto", display: "block" }}
>
<rect x="6" y="12" width="36" height="28" rx="3" stroke="#2e2e3e" strokeWidth="2" />
<path d="M6 20h36" stroke="#2e2e3e" strokeWidth="2" />
<path d="M16 8h16" stroke="#2e2e3e" strokeWidth="2" strokeLinecap="round" />
<circle cx="24" cy="32" r="6" stroke="#3b3b4e" strokeWidth="2" />
<path d="M18 32h12" stroke="#3b3b4e" strokeWidth="1.5" />
<path d="M24 26v12" stroke="#3b3b4e" strokeWidth="1.5" />
</svg>
</div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 8 }}>
Noch keine Leads gespeichert
</div>
<div style={{ fontSize: 13, color: "#9ca3af", marginBottom: 20 }}>
Starte eine Suche die Ergebnisse erscheinen hier automatisch.
</div>
<button
onClick={() => router.push("/suche")}
style={{
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
border: "none",
borderRadius: 8,
padding: "10px 20px",
fontSize: 13,
fontWeight: 500,
color: "#ffffff",
cursor: "pointer",
}}
>
Zur Suche
</button>
</div>
)
) : (
<LeadsTable
leads={leads}
selected={selected}
onToggleSelect={handleToggleSelect}
onToggleAll={handleToggleAll}
allSelected={selected.size === leads.length && leads.length > 0}
onLeadUpdate={handleLeadUpdate}
/>
)}
</div>
{/* Footer */}
{(total > 0 || leads.length > 0) && (
<div
style={{
borderTop: "1px solid #1e1e2e",
background: "#111118",
padding: "12px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
{/* Stats */}
<div style={{ fontSize: 12, color: "#6b7280" }}>
{total} Leads gesamt
{withEmailCount > 0 && (
<> · {withEmailCount} mit E-Mail ({withEmailPct}%)</>
)}
</div>
{/* Pagination */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<select
value={perPage}
onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1); }}
style={{
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 12,
color: "#9ca3af",
cursor: "pointer",
outline: "none",
}}
>
<option value={25}>25 / Seite</option>
<option value={50}>50 / Seite</option>
<option value={100}>100 / Seite</option>
</select>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 13,
color: page <= 1 ? "#3b3b4e" : "#9ca3af",
cursor: page <= 1 ? "not-allowed" : "pointer",
}}
>
</button>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
Seite {page} von {pages}
</span>
<button
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page >= pages}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 13,
color: page >= pages ? "#3b3b4e" : "#9ca3af",
cursor: page >= pages ? "not-allowed" : "pointer",
}}
>
</button>
</div>
</div>
</div>
)}
{/* Bulk action bar */}
<BulkActionBar
selectedCount={selected.size}
onSetStatus={handleBulkStatus}
onDelete={handleBulkDelete}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/airscale");
redirect("/suche");
}

100
app/suche/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchCard } from "@/components/search/SearchCard";
import { LoadingCard } from "@/components/search/LoadingCard";
export default function SuchePage() {
const router = useRouter();
const [query, setQuery] = useState("");
const [region, setRegion] = useState("");
const [count, setCount] = useState(50);
const [loading, setLoading] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
function handleChange(field: "query" | "region" | "count", value: string | number) {
if (field === "query") setQuery(value as string);
if (field === "region") setRegion(value as string);
if (field === "count") setCount(value as number);
}
async function handleSubmit() {
if (!query.trim() || loading) return;
setLoading(true);
setJobId(null);
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.trim(), region: region.trim(), count }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
throw new Error(err.error || "Fehler beim Starten der Suche");
}
const data = await res.json() as { jobId: string };
setJobId(data.jobId);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
toast.error(msg);
setLoading(false);
}
}
const handleDone = useCallback((total: number) => {
setLoading(false);
toast.success(`${total} Leads gefunden — Leadspeicher wird geöffnet`, {
duration: 3000,
});
setTimeout(() => {
router.push("/leadspeicher");
}, 1500);
}, [router]);
const handleError = useCallback(() => {
setLoading(false);
setJobId(null);
toast.error("Suche fehlgeschlagen. Bitte prüfe deine API-Einstellungen.");
}, []);
return (
<div
style={{
padding: "40px 40px",
maxWidth: 680,
margin: "0 auto",
}}
>
{/* Hero */}
<div style={{ textAlign: "center", marginBottom: 32 }}>
<h2 style={{ fontSize: 22, fontWeight: 500, color: "#ffffff", margin: 0, marginBottom: 8 }}>
Leads finden
</h2>
<p style={{ fontSize: 13, color: "#9ca3af", margin: 0 }}>
Suchbegriff eingeben wir finden passende Unternehmen mit Kontaktdaten.
</p>
</div>
{/* Search Card */}
<SearchCard
query={query}
region={region}
count={count}
loading={loading}
onChange={handleChange}
onSubmit={handleSubmit}
/>
{/* Loading Card */}
{loading && jobId && (
<LoadingCard
jobId={jobId}
onDone={handleDone}
onError={handleError}
/>
)}
</div>
);
}

View File

@@ -1,38 +1,137 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAppStore } from "@/lib/store";
import { Activity } from "lucide-react";
import { useEffect, useState } from "react";
const BREADCRUMBS: Record<string, string> = {
"/airscale": "AirScale → E-Mail",
"/linkedin": "LinkedIn → E-Mail",
"/serp": "SERP → E-Mail",
"/maps": "Google Maps → E-Mail",
"/leadvault": "🗄️ Leadspeicher",
"/results": "Ergebnisse & Verlauf",
"/settings": "Einstellungen",
};
function OnyvaLogo() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{/* 5-pointed star SVG */}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polygon
points="7,1 8.5,5.5 13,5.5 9.5,8.5 10.8,13 7,10.2 3.2,13 4.5,8.5 1,5.5 5.5,5.5"
fill="white"
/>
</svg>
</div>
<span style={{ fontSize: 15, fontWeight: 500, color: "#ffffff" }}>OnyvaLeads</span>
</div>
);
}
export function TopBar() {
export function Topbar() {
const pathname = usePathname();
const { activeJobs } = useAppStore();
const runningJobs = activeJobs.filter(j => j.status === "running").length;
const label = BREADCRUMBS[pathname] || "Dashboard";
const [newLeadsCount, setNewLeadsCount] = useState(0);
useEffect(() => {
function fetchNewLeads() {
fetch("/api/leads/stats")
.then((r) => r.json())
.then((d: { new?: number }) => setNewLeadsCount(d.new ?? 0))
.catch(() => {});
}
fetchNewLeads();
const t = setInterval(fetchNewLeads, 30000);
return () => clearInterval(t);
}, []);
const tabs = [
{ href: "/suche", label: "Suche" },
{ href: "/leadspeicher", label: "Leadspeicher" },
];
return (
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">OnyvaLeads</span>
<span className="text-gray-600">/</span>
<span className="text-white font-medium">{label}</span>
<header
style={{
position: "sticky",
top: 0,
zIndex: 50,
height: 52,
background: "#111118",
borderBottom: "1px solid #1e1e2e",
display: "flex",
alignItems: "center",
paddingLeft: 20,
paddingRight: 20,
gap: 16,
flexShrink: 0,
}}
>
{/* Logo */}
<Link href="/suche" style={{ textDecoration: "none" }}>
<OnyvaLogo />
</Link>
{/* Tab switcher */}
<div
style={{
background: "#0a0a0f",
borderRadius: 8,
padding: 3,
display: "flex",
gap: 2,
}}
>
{tabs.map((tab) => {
const isActive = pathname === tab.href || pathname.startsWith(tab.href + "/");
return (
<Link
key={tab.href}
href={tab.href}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: isActive ? 500 : 400,
color: isActive ? "#ffffff" : "#9ca3af",
background: isActive ? "#111118" : "transparent",
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.2)" : "none",
textDecoration: "none",
transition: "all 0.15s",
whiteSpace: "nowrap",
}}
>
{tab.label}
{tab.href === "/leadspeicher" && newLeadsCount > 0 && (
<span
style={{
background: "#3b82f6",
color: "#ffffff",
fontSize: 10,
fontWeight: 600,
minWidth: 18,
height: 18,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 4px",
lineHeight: 1,
}}
>
{newLeadsCount > 99 ? "99+" : newLeadsCount}
</span>
)}
</Link>
);
})}
</div>
{runningJobs > 0 && (
<div className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-3 py-1">
<Activity className="w-3.5 h-3.5 text-blue-400 animate-pulse" />
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { STATUS_OPTIONS } from "./StatusBadge";
interface BulkActionBarProps {
selectedCount: number;
onSetStatus: (status: string) => void;
onDelete: () => void;
}
export function BulkActionBar({ selectedCount, onSetStatus, onDelete }: BulkActionBarProps) {
const [showStatusMenu, setShowStatusMenu] = useState(false);
if (selectedCount === 0) return null;
return (
<div
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 16px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 200,
boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
animation: "slideUp 0.15s ease",
}}
>
<span style={{ fontSize: 13, color: "#9ca3af", whiteSpace: "nowrap" }}>
{selectedCount} ausgewählt
</span>
<span style={{ color: "#1e1e2e" }}>·</span>
{/* Status dropdown */}
<div style={{ position: "relative" }}>
<button
onClick={() => setShowStatusMenu((v) => !v)}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "5px 12px",
fontSize: 13,
color: "#ffffff",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
Status setzen
</button>
{showStatusMenu && (
<div
style={{
position: "absolute",
bottom: "calc(100% + 4px)",
left: 0,
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: 4,
minWidth: 160,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
zIndex: 300,
}}
>
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => { onSetStatus(opt.value); setShowStatusMenu(false); }}
style={{
width: "100%",
display: "block",
padding: "7px 10px",
borderRadius: 6,
fontSize: 13,
color: "#9ca3af",
background: "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#1e1e2e"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
{opt.label}
</button>
))}
</div>
)}
</div>
{/* Delete */}
<button
onClick={onDelete}
style={{
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
padding: "5px 12px",
fontSize: 13,
color: "#ef4444",
cursor: "pointer",
}}
>
Löschen
</button>
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,412 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
industry: string | null;
sourceTerm: string | null;
address: string | null;
status: string;
notes: string | null;
capturedAt: string;
}
interface LeadSidePanelProps {
lead: Lead;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function LeadSidePanel({ lead, onClose, onUpdate }: LeadSidePanelProps) {
const [notes, setNotes] = useState(lead.notes || "");
const [status, setStatus] = useState(lead.status);
const [saved, setSaved] = useState(false);
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setNotes(lead.notes || "");
setStatus(lead.status);
}, [lead.id, lead.notes, lead.status]);
const saveNotes = useCallback(
async (value: string) => {
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: value }),
});
onUpdate(lead.id, { notes: value });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {
// silently fail
}
},
[lead.id, onUpdate]
);
function handleNotesChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const val = e.target.value;
setNotes(val);
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => saveNotes(val), 500);
}
async function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newStatus = e.target.value;
setStatus(newStatus);
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
onUpdate(lead.id, { status: newStatus });
} catch {
// silently fail
}
}
async function copyEmail() {
if (lead.email) {
await navigator.clipboard.writeText(lead.email);
}
}
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 300,
}}
/>
{/* Panel */}
<div
style={{
position: "fixed",
top: 0,
right: 0,
bottom: 0,
width: 380,
background: "#111118",
borderLeft: "1px solid #1e1e2e",
zIndex: 400,
overflowY: "auto",
padding: 24,
animation: "slideInRight 0.2s ease",
}}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 2 }}>
{lead.companyName || lead.domain || "Unbekannt"}
</div>
{lead.domain && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</div>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "#6b7280",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: 4,
}}
>
&times;
</button>
</div>
{/* Status */}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Status
</label>
<select
value={status}
onChange={handleStatusChange}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: STATUS_MAP[status]?.color ?? "#ffffff",
cursor: "pointer",
outline: "none",
}}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Email */}
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
E-Mail
</label>
{lead.email ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: "monospace",
fontSize: 13,
color: "#3b82f6",
flex: 1,
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={copyEmail}
title="Kopieren"
style={{
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
flexShrink: 0,
}}
>
Kopieren
</button>
</div>
) : (
<span style={{ fontSize: 13, color: "#6b7280" }}> nicht gefunden</span>
)}
</div>
{/* LinkedIn */}
{lead.linkedinUrl && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
LinkedIn
</label>
<a
href={lead.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: "#3b82f6", wordBreak: "break-all" }}
>
{lead.linkedinUrl}
</a>
</div>
)}
{/* Phone */}
{lead.phone && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Telefon
</label>
<span style={{ fontSize: 13, color: "#ffffff" }}>{lead.phone}</span>
</div>
)}
{/* Industry */}
{lead.industry && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Branche
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>{lead.industry}</span>
</div>
)}
{/* Region */}
{(lead.sourceTerm || lead.address) && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Region
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm}
</span>
</div>
)}
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Notes */}
<div style={{ marginBottom: 14 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<label
style={{
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Notiz
</label>
{saved && (
<span style={{ fontSize: 11, color: "#22c55e" }}>Gespeichert </span>
)}
</div>
<textarea
value={notes}
onChange={handleNotesChange}
placeholder="Notizen zu diesem Lead…"
rows={4}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: "#ffffff",
resize: "vertical",
outline: "none",
fontFamily: "inherit",
boxSizing: "border-box",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "#1e1e2e";
}}
/>
</div>
{/* Meta */}
<div
style={{
borderTop: "1px solid #1e1e2e",
paddingTop: 16,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Gefunden am</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{formatDate(lead.capturedAt)}</span>
</div>
{lead.sourceTerm && (
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Suchbegriff</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{lead.sourceTerm}</span>
</div>
)}
</div>
</div>
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
</>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import { useState, useCallback } from "react";
import { StatusBadge } from "./StatusBadge";
import { StatusPopover } from "./StatusPopover";
import { LeadSidePanel } from "./LeadSidePanel";
export interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
address: string | null;
sourceTab: string;
sourceTerm: string | null;
sourceJobId: string | null;
serpTitle: string | null;
serpSnippet: string | null;
serpRank: number | null;
serpUrl: string | null;
status: string;
priority: string;
notes: string | null;
tags: string | null;
country: string | null;
headcount: string | null;
industry: string | null;
description: string | null;
capturedAt: string;
contactedAt: string | null;
}
interface LeadsTableProps {
leads: Lead[];
selected: Set<string>;
onToggleSelect: (id: string) => void;
onToggleAll: () => void;
allSelected: boolean;
onLeadUpdate: (id: string, updates: Partial<Lead>) => void;
}
function relativeDate(iso: string): string {
const now = new Date();
const d = new Date(iso);
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "heute";
if (diffDays === 1) return "gestern";
if (diffDays < 7) return `vor ${diffDays} Tagen`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `vor ${diffWeeks} Woche${diffWeeks > 1 ? "n" : ""}`;
const diffMonths = Math.floor(diffDays / 30);
return `vor ${diffMonths} Monat${diffMonths > 1 ? "en" : ""}`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
interface StatusCellProps {
lead: Lead;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function StatusCell({ lead, onUpdate }: StatusCellProps) {
const [open, setOpen] = useState(false);
async function handleSelect(newStatus: string) {
// Optimistic update
onUpdate(lead.id, { status: newStatus });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
} catch {
// revert on error
onUpdate(lead.id, { status: lead.status });
}
}
return (
<div style={{ position: "relative", display: "inline-block" }}>
<StatusBadge status={lead.status} onClick={() => setOpen((v) => !v)} />
{open && (
<StatusPopover
currentStatus={lead.status}
onSelect={handleSelect}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
export function LeadsTable({
leads,
selected,
onToggleSelect,
onToggleAll,
allSelected,
onLeadUpdate,
}: LeadsTableProps) {
const [sidePanelLead, setSidePanelLead] = useState<Lead | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyEmail = useCallback(async (lead: Lead, e: React.MouseEvent) => {
e.stopPropagation();
if (!lead.email) return;
await navigator.clipboard.writeText(lead.email);
setCopiedId(lead.id);
setTimeout(() => setCopiedId(null), 1500);
}, []);
async function handleMarkContacted(lead: Lead) {
onLeadUpdate(lead.id, { status: "contacted" });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "contacted" }),
});
} catch {
onLeadUpdate(lead.id, { status: lead.status });
}
}
const thStyle: React.CSSProperties = {
padding: "10px 16px",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
textAlign: "left",
whiteSpace: "nowrap",
background: "#0d0d18",
borderBottom: "1px solid #1e1e2e",
};
return (
<>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ ...thStyle, width: 40 }}>
<input
type="checkbox"
checked={allSelected && leads.length > 0}
onChange={onToggleAll}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</th>
<th style={thStyle}>Unternehmen</th>
<th style={thStyle}>E-Mail</th>
<th style={thStyle}>Branche</th>
<th style={thStyle}>Region</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Gefunden am</th>
<th style={{ ...thStyle, textAlign: "right" }}>Aktionen</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => {
const isSelected = selected.has(lead.id);
const isNew = lead.status === "new";
return (
<tr
key={lead.id}
style={{
background: isSelected ? "rgba(59,130,246,0.04)" : "transparent",
borderBottom: "1px solid #1e1e2e",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "#0d0d18";
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "transparent";
}}
>
{/* Checkbox */}
<td style={{ padding: "11px 16px", width: 40 }}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(lead.id)}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</td>
{/* Unternehmen */}
<td style={{ padding: "11px 16px", maxWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: "#ffffff", marginBottom: 1 }}>
{lead.companyName || lead.domain || "—"}
</div>
{lead.domain && lead.companyName && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</td>
{/* Email */}
<td style={{ padding: "11px 16px", maxWidth: 220 }}>
{lead.email ? (
<div
className="email-cell"
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "#3b82f6",
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={(e) => handleCopyEmail(lead, e)}
title="Kopieren"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
color: copiedId === lead.id ? "#22c55e" : "#6b7280",
flexShrink: 0,
fontSize: 11,
}}
>
{copiedId === lead.id ? "✓" : "⎘"}
</button>
</div>
) : (
<span style={{ fontSize: 11, color: "#6b7280" }}> nicht gefunden</span>
)}
</td>
{/* Branche */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.industry || "—"}
</span>
</td>
{/* Region */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm ?? "—"}
</span>
</td>
{/* Status */}
<td style={{ padding: "11px 16px" }}>
<StatusCell lead={lead} onUpdate={onLeadUpdate} />
</td>
{/* Gefunden am */}
<td style={{ padding: "11px 16px" }}>
<span
style={{ fontSize: 12, color: "#9ca3af" }}
title={fullDate(lead.capturedAt)}
>
{relativeDate(lead.capturedAt)}
</span>
</td>
{/* Aktionen */}
<td style={{ padding: "11px 16px", textAlign: "right", whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
{/* Primary action */}
<button
onClick={() => (isNew ? handleMarkContacted(lead) : undefined)}
style={{
background: isNew ? "rgba(59,130,246,0.12)" : "#0d0d18",
border: `1px solid ${isNew ? "rgba(59,130,246,0.3)" : "#2e2e3e"}`,
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: isNew ? "#93c5fd" : "#9ca3af",
cursor: "pointer",
transition: "all 0.1s",
}}
>
{isNew ? "Kontaktiert ✓" : "Status ändern"}
</button>
{/* Notiz */}
<button
onClick={() => setSidePanelLead(lead)}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
}}
>
Notiz
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Side panel */}
{sidePanelLead && (
<LeadSidePanel
lead={sidePanelLead}
onClose={() => setSidePanelLead(null)}
onUpdate={(id, updates) => {
onLeadUpdate(id, updates);
setSidePanelLead((prev) => (prev && prev.id === id ? { ...prev, ...updates } : prev));
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useRef } from "react";
interface LeadsToolbarProps {
search: string;
status: string;
onSearchChange: (v: string) => void;
onStatusChange: (v: string) => void;
exportParams: Record<string, string>;
}
export function LeadsToolbar({
search,
status,
onSearchChange,
onStatusChange,
exportParams,
}: LeadsToolbarProps) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => onSearchChange(val), 300);
// immediate update of input
e.currentTarget.value = val;
}
function handleExport() {
const params = new URLSearchParams({ format: "xlsx", ...exportParams });
window.location.href = `/api/leads/export?${params.toString()}`;
}
return (
<div
style={{
position: "sticky",
top: 52,
zIndex: 40,
background: "#111118",
borderBottom: "1px solid #1e1e2e",
padding: "14px 20px",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Search input */}
<div style={{ flex: 1, position: "relative" }}>
<div
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
color: "#6b7280",
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<line x1="9.5" y1="9.5" x2="12.5" y2="12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
<input
type="text"
defaultValue={search}
onChange={handleSearchInput}
placeholder="Unternehmen, E-Mail, Branche…"
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px 8px 32px",
fontSize: 13,
color: "#ffffff",
outline: "none",
boxSizing: "border-box",
}}
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/>
</div>
{/* Status filter */}
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
style={{
width: 150,
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: "#ffffff",
cursor: "pointer",
outline: "none",
flexShrink: 0,
}}
>
<option value="">Alle Status</option>
<option value="new">Neu</option>
<option value="contacted">Kontaktiert</option>
<option value="in_progress">In Bearbeitung</option>
<option value="not_relevant">Nicht relevant</option>
<option value="converted">Konvertiert</option>
</select>
{/* Export button */}
<button
onClick={handleExport}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 8,
padding: "8px 14px",
fontSize: 13,
color: "#9ca3af",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 6,
transition: "color 0.15s",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#ffffff"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
>
Excel Export
</button>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
export type LeadStatus = "new" | "contacted" | "in_progress" | "not_relevant" | "converted";
interface StatusConfig {
label: string;
background: string;
color: string;
}
export const STATUS_MAP: Record<string, StatusConfig> = {
new: { label: "Neu", background: "#1e3a5f", color: "#93c5fd" },
contacted: { label: "Kontaktiert", background: "#064e3b", color: "#6ee7b7" },
in_progress: { label: "In Bearbeitung", background: "#451a03", color: "#fcd34d" },
not_relevant: { label: "Nicht relevant", background: "#1e1e2e", color: "#6b7280" },
converted: { label: "Konvertiert", background: "#2e1065", color: "#d8b4fe" },
};
export const STATUS_OPTIONS = [
{ value: "new", label: "Neu" },
{ value: "contacted", label: "Kontaktiert" },
{ value: "in_progress", label: "In Bearbeitung" },
{ value: "not_relevant", label: "Nicht relevant" },
{ value: "converted", label: "Konvertiert" },
];
interface StatusBadgeProps {
status: string;
onClick?: (e: React.MouseEvent) => void;
}
export function StatusBadge({ status, onClick }: StatusBadgeProps) {
const cfg = STATUS_MAP[status] ?? STATUS_MAP.new;
return (
<span
onClick={onClick}
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 10px",
borderRadius: 20,
fontSize: 12,
fontWeight: 500,
background: cfg.background,
color: cfg.color,
cursor: onClick ? "pointer" : "default",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{cfg.label}
</span>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef } from "react";
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
interface StatusPopoverProps {
currentStatus: string;
onSelect: (status: string) => void;
onClose: () => void;
}
export function StatusPopover({ currentStatus, onSelect, onClose }: StatusPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
return (
<div
ref={ref}
style={{
position: "absolute",
zIndex: 100,
top: "calc(100% + 4px)",
left: 0,
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: 4,
minWidth: 160,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
>
{STATUS_OPTIONS.map((opt) => {
const cfg = STATUS_MAP[opt.value];
const isActive = opt.value === currentStatus;
return (
<button
key={opt.value}
onClick={() => { onSelect(opt.value); onClose(); }}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderRadius: 6,
fontSize: 13,
color: isActive ? "#ffffff" : "#9ca3af",
background: isActive ? "#1e1e2e" : "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
transition: "background 0.1s",
}}
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "#0d0d18"; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: 4,
background: cfg.color,
flexShrink: 0,
}}
/>
{opt.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
interface ExamplePill {
label: string;
query: string;
region: string;
}
const EXAMPLES: ExamplePill[] = [
{ label: "☀️ Solaranlagen Bayern", query: "Solaranlagen", region: "Bayern" },
{ label: "🔧 Dachdecker NRW", query: "Dachdecker", region: "NRW" },
{ label: "📊 Steuerberater Berlin", query: "Steuerberater", region: "Berlin" },
{ label: "🏗️ Bauunternehmen Süddeutschland", query: "Bauunternehmen", region: "Süddeutschland" },
{ label: "🌿 Landschaftsgärtner Hamburg", query: "Landschaftsgärtner", region: "Hamburg" },
{ label: "🔌 Elektroinstallateur München", query: "Elektroinstallateur", region: "München" },
];
interface ExamplePillsProps {
onSelect: (query: string, region: string) => void;
}
export function ExamplePills({ onSelect }: ExamplePillsProps) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{EXAMPLES.map((ex) => (
<button
key={ex.label}
onClick={() => onSelect(ex.query, ex.region)}
style={{
padding: "5px 12px",
border: "1px solid #2e2e3e",
borderRadius: 20,
fontSize: 12,
color: "#9ca3af",
background: "transparent",
cursor: "pointer",
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = "#3b82f6";
(e.currentTarget as HTMLButtonElement).style.color = "#3b82f6";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = "#2e2e3e";
(e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
}}
>
{ex.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
type Phase = "scraping" | "enriching" | "emails" | "done";
interface JobStatus {
status: "running" | "complete" | "failed";
totalLeads: number;
emailsFound: number;
}
interface LoadingCardProps {
jobId: string;
onDone: (totalFound: number) => void;
onError: () => void;
}
const STEPS = [
{ id: "scraping" as Phase, label: "Unternehmen gefunden" },
{ id: "enriching" as Phase, label: "Kontakte ermitteln" },
{ id: "emails" as Phase, label: "E-Mails suchen" },
{ id: "done" as Phase, label: "Fertig" },
];
function getPhase(s: JobStatus): Phase {
if (s.status === "complete") return "done";
if (s.emailsFound > 0) return "emails";
if (s.totalLeads > 0) return "enriching";
return "scraping";
}
function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pending" {
const order: Phase[] = ["scraping", "enriching", "emails", "done"];
const stepIdx = order.indexOf(step);
const currentIdx = order.indexOf(currentPhase);
if (stepIdx < currentIdx) return "done";
if (stepIdx === currentIdx) return "active";
return "pending";
}
export function LoadingCard({ jobId, onDone, onError }: LoadingCardProps) {
const [jobStatus, setJobStatus] = useState<JobStatus>({
status: "running",
totalLeads: 0,
emailsFound: 0,
});
const [progressWidth, setProgressWidth] = useState(40);
useEffect(() => {
let cancelled = false;
let pendulumInterval: ReturnType<typeof setInterval> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
// Pendulum animation while running
let goingUp = true;
pendulumInterval = setInterval(() => {
if (cancelled) return;
setProgressWidth((prev) => {
if (goingUp) {
if (prev >= 85) { goingUp = false; return 84; }
return prev + 1;
} else {
if (prev <= 40) { goingUp = true; return 41; }
return prev - 1;
}
});
}, 120);
async function poll() {
if (cancelled) return;
try {
const res = await fetch(`/api/jobs/${jobId}/status`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as JobStatus;
if (!cancelled) {
setJobStatus(data);
if (data.status === "complete") {
if (pendulumInterval) clearInterval(pendulumInterval);
setProgressWidth(100);
setTimeout(() => {
if (!cancelled) onDone(data.totalLeads);
}, 800);
} else if (data.status === "failed") {
if (pendulumInterval) clearInterval(pendulumInterval);
onError();
} else {
pollTimeout = setTimeout(poll, 2500);
}
}
} catch {
if (!cancelled) {
pollTimeout = setTimeout(poll, 3000);
}
}
}
poll();
return () => {
cancelled = true;
if (pendulumInterval) clearInterval(pendulumInterval);
if (pollTimeout) clearTimeout(pollTimeout);
};
}, [jobId, onDone, onError]);
const phase = getPhase(jobStatus);
return (
<div
style={{
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 12,
padding: 24,
marginTop: 16,
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
Ergebnisse werden gesucht
</span>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{jobStatus.totalLeads > 0 || jobStatus.emailsFound > 0
? `${jobStatus.emailsFound} von ${jobStatus.totalLeads} gefunden`
: "Wird gestartet…"}
</span>
</div>
{/* Progress bar */}
<div
style={{
height: 5,
borderRadius: 3,
background: "#1e1e2e",
overflow: "hidden",
marginBottom: 16,
}}
>
<div
style={{
height: "100%",
width: `${progressWidth}%`,
background: "linear-gradient(90deg, #3b82f6, #8b5cf6)",
borderRadius: 3,
transition: phase === "done" ? "width 0.5s ease" : "width 0.12s linear",
}}
/>
</div>
{/* Steps */}
<div style={{ display: "flex", gap: 20, marginBottom: 16 }}>
{STEPS.map((step) => {
const state = stepState(step.id, phase);
return (
<div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div
style={{
width: 6,
height: 6,
borderRadius: 3,
background:
state === "done"
? "#22c55e"
: state === "active"
? "#3b82f6"
: "#2e2e3e",
animation: state === "active" ? "pulse 1.5s infinite" : "none",
}}
/>
<span
style={{
fontSize: 12,
color:
state === "done"
? "#22c55e"
: state === "active"
? "#ffffff"
: "#6b7280",
}}
>
{step.label}
</span>
</div>
);
})}
</div>
{/* Email note */}
{phase === "emails" && (
<p style={{ fontSize: 11, color: "#6b7280", fontStyle: "italic", marginBottom: 12 }}>
E-Mail-Suche kann einen Moment dauern.
</p>
)}
{/* Warning banner */}
<div
style={{
borderLeft: "3px solid #f59e0b",
background: "rgba(245,158,11,0.08)",
padding: "10px 14px",
borderRadius: 8,
fontSize: 12,
color: "#9ca3af",
}}
>
Bitte diesen Tab nicht schließen, während die Suche läuft.
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,207 @@
"use client";
import { ExamplePills } from "./ExamplePills";
interface SearchCardProps {
query: string;
region: string;
count: number;
loading: boolean;
onChange: (field: "query" | "region" | "count", value: string | number) => void;
onSubmit: () => void;
}
export function SearchCard({ query, region, count, loading, onChange, onSubmit }: SearchCardProps) {
const canSubmit = query.trim().length > 0 && !loading;
return (
<div
style={{
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 12,
padding: 24,
opacity: loading ? 0.55 : 1,
pointerEvents: loading ? "none" : "auto",
transition: "opacity 0.2s",
}}
>
{/* 3-column grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 120px",
gap: 12,
marginBottom: 16,
}}
>
{/* Suchbegriff */}
<div>
<label
style={{
display: "block",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Suchbegriff
</label>
<input
type="text"
value={query}
onChange={(e) => onChange("query", e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
placeholder="z.B. Dachdecker, Solaranlage, Steuerberater…"
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "9px 12px",
fontSize: 14,
color: "#ffffff",
outline: "none",
boxSizing: "border-box",
transition: "border-color 0.15s",
}}
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/>
</div>
{/* Region */}
<div>
<label
style={{
display: "block",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Region
</label>
<input
type="text"
value={region}
onChange={(e) => onChange("region", e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
placeholder="z.B. Bayern, München, Deutschland"
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "9px 12px",
fontSize: 14,
color: "#ffffff",
outline: "none",
boxSizing: "border-box",
transition: "border-color 0.15s",
}}
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/>
</div>
{/* Anzahl */}
<div>
<label
style={{
display: "block",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Anzahl
</label>
<select
value={count}
onChange={(e) => onChange("count", Number(e.target.value))}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "9px 12px",
fontSize: 14,
color: "#ffffff",
outline: "none",
cursor: "pointer",
boxSizing: "border-box",
}}
>
<option value={25}>25 Leads</option>
<option value={50}>50 Leads</option>
<option value={100}>100 Leads</option>
<option value={200}>200 Leads</option>
</select>
</div>
</div>
{/* Submit button */}
<button
onClick={onSubmit}
disabled={!canSubmit}
style={{
width: "100%",
background: canSubmit ? "linear-gradient(135deg, #3b82f6, #8b5cf6)" : "#1e1e2e",
color: canSubmit ? "#ffffff" : "#6b7280",
border: "none",
borderRadius: 10,
padding: "12px 16px",
fontSize: 14,
fontWeight: 500,
cursor: canSubmit ? "pointer" : "not-allowed",
opacity: canSubmit ? 1 : 0.5,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginBottom: 20,
transition: "opacity 0.15s",
}}
>
{/* Magnifying glass icon */}
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<line x1="10.5" y1="10.5" x2="13.5" y2="13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Leads suchen
</button>
{/* Divider */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 16,
}}
>
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
<span style={{ fontSize: 12, color: "#6b7280", flexShrink: 0 }}>Beispielsuche</span>
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
</div>
{/* Example pills */}
<ExamplePills
onSelect={(q, r) => {
onChange("query", q);
onChange("region", r);
}}
/>
</div>
);
}