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 { 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
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user