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:
40
app/api/search/route.ts
Normal file
40
app/api/search/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import "./globals.css";
|
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";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OnyvaLeads — Lead Generation Platform",
|
title: "OnyvaLeads",
|
||||||
description: "Unified lead generation and email enrichment platform",
|
description: "Leads finden und verwalten",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="de" className="dark">
|
||||||
<body className={`${inter.variable} antialiased`}>
|
<body className="antialiased">
|
||||||
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
|
<div className="flex flex-col min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||||
<Sidebar />
|
<Topbar />
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<TopBar />
|
{children}
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Toaster position="bottom-right" theme="dark" />
|
<Toaster position="bottom-right" theme="dark" />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
335
app/leadspeicher/page.tsx
Normal file
335
app/leadspeicher/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/airscale");
|
redirect("/suche");
|
||||||
}
|
}
|
||||||
|
|||||||
100
app/suche/page.tsx
Normal file
100
app/suche/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,137 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAppStore } from "@/lib/store";
|
import { useEffect, useState } from "react";
|
||||||
import { Activity } from "lucide-react";
|
|
||||||
|
|
||||||
const BREADCRUMBS: Record<string, string> = {
|
function OnyvaLogo() {
|
||||||
"/airscale": "AirScale → E-Mail",
|
return (
|
||||||
"/linkedin": "LinkedIn → E-Mail",
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
"/serp": "SERP → E-Mail",
|
<div
|
||||||
"/maps": "Google Maps → E-Mail",
|
style={{
|
||||||
"/leadvault": "🗄️ Leadspeicher",
|
width: 24,
|
||||||
"/results": "Ergebnisse & Verlauf",
|
height: 24,
|
||||||
"/settings": "Einstellungen",
|
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 pathname = usePathname();
|
||||||
const { activeJobs } = useAppStore();
|
const [newLeadsCount, setNewLeadsCount] = useState(0);
|
||||||
const runningJobs = activeJobs.filter(j => j.status === "running").length;
|
|
||||||
const label = BREADCRUMBS[pathname] || "Dashboard";
|
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 (
|
return (
|
||||||
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
|
<header
|
||||||
<div className="flex items-center gap-2 text-sm">
|
style={{
|
||||||
<span className="text-gray-500">OnyvaLeads</span>
|
position: "sticky",
|
||||||
<span className="text-gray-600">/</span>
|
top: 0,
|
||||||
<span className="text-white font-medium">{label}</span>
|
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>
|
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
components/leadspeicher/BulkActionBar.tsx
Normal file
125
components/leadspeicher/BulkActionBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
412
components/leadspeicher/LeadSidePanel.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
components/leadspeicher/LeadsTable.tsx
Normal file
335
components/leadspeicher/LeadsTable.tsx
Normal 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));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
136
components/leadspeicher/LeadsToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
components/leadspeicher/StatusBadge.tsx
Normal file
54
components/leadspeicher/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
components/leadspeicher/StatusPopover.tsx
Normal file
82
components/leadspeicher/StatusPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/search/ExamplePills.tsx
Normal file
53
components/search/ExamplePills.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
components/search/LoadingCard.tsx
Normal file
220
components/search/LoadingCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
components/search/SearchCard.tsx
Normal file
207
components/search/SearchCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user