From 60073b97c9221fbee870ac43f206946d5ec6329e Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Fri, 27 Mar 2026 16:48:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20OnyvaLeads=20customer=20UI=20=E2=80=94?= =?UTF-8?q?=20Suche=20+=20Leadspeicher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/search/route.ts | 40 +++ app/layout.tsx | 27 +- app/leadspeicher/page.tsx | 335 ++++++++++++++++++ app/page.tsx | 2 +- app/suche/page.tsx | 100 ++++++ components/layout/TopBar.tsx | 151 ++++++-- components/leadspeicher/BulkActionBar.tsx | 125 +++++++ components/leadspeicher/LeadSidePanel.tsx | 412 ++++++++++++++++++++++ components/leadspeicher/LeadsTable.tsx | 335 ++++++++++++++++++ components/leadspeicher/LeadsToolbar.tsx | 136 +++++++ components/leadspeicher/StatusBadge.tsx | 54 +++ components/leadspeicher/StatusPopover.tsx | 82 +++++ components/search/ExamplePills.tsx | 53 +++ components/search/LoadingCard.tsx | 220 ++++++++++++ components/search/SearchCard.tsx | 207 +++++++++++ 15 files changed, 2235 insertions(+), 44 deletions(-) create mode 100644 app/api/search/route.ts create mode 100644 app/leadspeicher/page.tsx create mode 100644 app/suche/page.tsx create mode 100644 components/leadspeicher/BulkActionBar.tsx create mode 100644 components/leadspeicher/LeadSidePanel.tsx create mode 100644 components/leadspeicher/LeadsTable.tsx create mode 100644 components/leadspeicher/LeadsToolbar.tsx create mode 100644 components/leadspeicher/StatusBadge.tsx create mode 100644 components/leadspeicher/StatusPopover.tsx create mode 100644 components/search/ExamplePills.tsx create mode 100644 components/search/LoadingCard.tsx create mode 100644 components/search/SearchCard.tsx diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..1ba603b --- /dev/null +++ b/app/api/search/route.ts @@ -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 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 8960110..3e54b3b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - -
- -
- -
- {children} -
-
+ + +
+ +
+ {children} +
diff --git a/app/leadspeicher/page.tsx b/app/leadspeicher/page.tsx new file mode 100644 index 0000000..9e5a4c9 --- /dev/null +++ b/app/leadspeicher/page.tsx @@ -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([]); + 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>(new Set()); + const [stats, setStats] = useState({ 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) { + 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 = {}; + if (search) exportParams.search = search; + if (status) exportParams.status = status; + + return ( +
+ {/* Toolbar */} + { setSearch(v); setPage(1); }} + onStatusChange={(v) => { setStatus(v); setPage(1); }} + exportParams={exportParams} + /> + + {/* Table area */} +
+ {loading && leads.length === 0 ? ( + // Loading skeleton +
+ Wird geladen… +
+ ) : leads.length === 0 ? ( + // Empty state + hasFilter ? ( +
+
🔍
+
+ Keine Leads gefunden +
+
+ Versuche andere Filtereinstellungen. +
+ +
+ ) : ( +
+
+ + + + + + + + +
+
+ Noch keine Leads gespeichert +
+
+ Starte eine Suche — die Ergebnisse erscheinen hier automatisch. +
+ +
+ ) + ) : ( + 0} + onLeadUpdate={handleLeadUpdate} + /> + )} +
+ + {/* Footer */} + {(total > 0 || leads.length > 0) && ( +
+ {/* Stats */} +
+ {total} Leads gesamt + {withEmailCount > 0 && ( + <> · {withEmailCount} mit E-Mail ({withEmailPct}%) + )} +
+ + {/* Pagination */} +
+ + +
+ + + Seite {page} von {pages} + + +
+
+
+ )} + + {/* Bulk action bar */} + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 0480abd..fb8d0ed 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function Home() { - redirect("/airscale"); + redirect("/suche"); } diff --git a/app/suche/page.tsx b/app/suche/page.tsx new file mode 100644 index 0000000..855aeea --- /dev/null +++ b/app/suche/page.tsx @@ -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(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 ( +
+ {/* Hero */} +
+

+ Leads finden +

+

+ Suchbegriff eingeben — wir finden passende Unternehmen mit Kontaktdaten. +

+
+ + {/* Search Card */} + + + {/* Loading Card */} + {loading && jobId && ( + + )} +
+ ); +} diff --git a/components/layout/TopBar.tsx b/components/layout/TopBar.tsx index 51e1c44..590f734 100644 --- a/components/layout/TopBar.tsx +++ b/components/layout/TopBar.tsx @@ -1,38 +1,137 @@ "use client"; +import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useAppStore } from "@/lib/store"; -import { Activity } from "lucide-react"; +import { useEffect, useState } from "react"; -const BREADCRUMBS: Record = { - "/airscale": "AirScale → E-Mail", - "/linkedin": "LinkedIn → E-Mail", - "/serp": "SERP → E-Mail", - "/maps": "Google Maps → E-Mail", - "/leadvault": "🗄️ Leadspeicher", - "/results": "Ergebnisse & Verlauf", - "/settings": "Einstellungen", -}; +function OnyvaLogo() { + return ( +
+
+ {/* 5-pointed star SVG */} + + + +
+ OnyvaLeads +
+ ); +} -export function TopBar() { +export function Topbar() { const pathname = usePathname(); - const { activeJobs } = useAppStore(); - const runningJobs = activeJobs.filter(j => j.status === "running").length; - const label = BREADCRUMBS[pathname] || "Dashboard"; + const [newLeadsCount, setNewLeadsCount] = useState(0); + + useEffect(() => { + function fetchNewLeads() { + fetch("/api/leads/stats") + .then((r) => r.json()) + .then((d: { new?: number }) => setNewLeadsCount(d.new ?? 0)) + .catch(() => {}); + } + fetchNewLeads(); + const t = setInterval(fetchNewLeads, 30000); + return () => clearInterval(t); + }, []); + + const tabs = [ + { href: "/suche", label: "Suche" }, + { href: "/leadspeicher", label: "Leadspeicher" }, + ]; return ( -
-
- OnyvaLeads - / - {label} +
+ {/* Logo */} + + + + + {/* Tab switcher */} +
+ {tabs.map((tab) => { + const isActive = pathname === tab.href || pathname.startsWith(tab.href + "/"); + return ( + + {tab.label} + {tab.href === "/leadspeicher" && newLeadsCount > 0 && ( + + {newLeadsCount > 99 ? "99+" : newLeadsCount} + + )} + + ); + })}
- {runningJobs > 0 && ( -
- - {runningJobs} Active Job{runningJobs > 1 ? "s" : ""} -
- )}
); } diff --git a/components/leadspeicher/BulkActionBar.tsx b/components/leadspeicher/BulkActionBar.tsx new file mode 100644 index 0000000..bc15cd9 --- /dev/null +++ b/components/leadspeicher/BulkActionBar.tsx @@ -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 ( +
+ + {selectedCount} ausgewählt + + · + + {/* Status dropdown */} +
+ + {showStatusMenu && ( +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+ + {/* Delete */} + + + +
+ ); +} diff --git a/components/leadspeicher/LeadSidePanel.tsx b/components/leadspeicher/LeadSidePanel.tsx new file mode 100644 index 0000000..9c89514 --- /dev/null +++ b/components/leadspeicher/LeadSidePanel.tsx @@ -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) => 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 | 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) { + 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) { + 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 */} +
+ {/* Panel */} +
+ {/* Header */} +
+
+
+ {lead.companyName || lead.domain || "Unbekannt"} +
+ {lead.domain && ( +
{lead.domain}
+ )} +
+ +
+ + {/* Status */} +
+ + +
+ +
+ + {/* Email */} +
+ + {lead.email ? ( +
+ + {lead.email} + + +
+ ) : ( + — nicht gefunden + )} +
+ + {/* LinkedIn */} + {lead.linkedinUrl && ( +
+ + + {lead.linkedinUrl} + +
+ )} + + {/* Phone */} + {lead.phone && ( +
+ + {lead.phone} +
+ )} + + {/* Industry */} + {lead.industry && ( +
+ + {lead.industry} +
+ )} + + {/* Region */} + {(lead.sourceTerm || lead.address) && ( +
+ + + {lead.address ?? lead.sourceTerm} + +
+ )} + +
+ + {/* Notes */} +
+
+ + {saved && ( + Gespeichert ✓ + )} +
+