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