UI improvements: Leadspeicher, Maps enrichment, exports
- Rename LeadVault → Leadspeicher throughout (sidebar, topbar, page) - SidePanel: full lead detail view with contact, source, tags (read-only), Google Maps link for address - Tags: kontaktiert stored as tag (toggleable), favorit tag toggle - Remove Status column, StatusBadge dropdown, Priority feature - Remove Aktualisieren button from Leadspeicher - Bulk actions: remove status dropdown - Export: LeadVault Excel-only, clean columns, freeze row + autofilter - Export dropdown: click-based (fix overflow-hidden clipping) - ExportButtons: remove CSV, Excel only everywhere - Maps page: post-search Anymailfinder enrichment button - ProgressCard: "Suche läuft..." instead of "Warte auf Anymailfinder-Server..." - Quick SERP renamed to "Schnell neue Suche" - Results page: Excel export, always-enabled download button - Anymailfinder: fix bulk field names, array-of-arrays format - Apify: fix countryCode lowercase - API: sourceTerm search, contacted/favorite tag filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink,
|
||||
Mail, Star, Tag, FileText, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
CheckSquare, Square, Download, Plus, RefreshCw, Phone
|
||||
Mail, Star, Tag, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
CheckSquare, Square, Download, Phone, Copy, MapPin, Building2, Globe
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
@@ -31,7 +31,6 @@ interface Lead {
|
||||
serpSnippet: string | null;
|
||||
serpRank: number | null;
|
||||
serpUrl: string | null;
|
||||
emailConfidence: number | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
notes: string | null;
|
||||
@@ -39,6 +38,7 @@ interface Lead {
|
||||
country: string | null;
|
||||
headcount: string | null;
|
||||
industry: string | null;
|
||||
description: string | null;
|
||||
capturedAt: string;
|
||||
contactedAt: string | null;
|
||||
events?: Array<{ id: string; event: string; at: string }>;
|
||||
@@ -71,12 +71,6 @@ const SOURCE_CONFIG: Record<string, { label: string; icon: string }> = {
|
||||
maps: { label: "Maps", icon: "📍" },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
|
||||
high: { label: "Hoch", icon: "↑", color: "text-red-400" },
|
||||
normal: { label: "Normal", icon: "→", color: "text-gray-400" },
|
||||
low: { label: "Niedrig",icon: "↓", color: "text-blue-400" },
|
||||
};
|
||||
|
||||
const TAG_PRESETS = [
|
||||
"solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt",
|
||||
"kaltakquise", "follow-up", "interessiert", "angebot-gesendet",
|
||||
@@ -97,18 +91,6 @@ function tagColor(tag: string): string {
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "gerade eben";
|
||||
if (mins < 60) return `vor ${mins} Min.`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `vor ${hrs} Std.`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `vor ${days} Tagen`;
|
||||
return new Date(dateStr).toLocaleDateString("de-DE");
|
||||
}
|
||||
|
||||
// ─── Sparkline ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
@@ -123,243 +105,163 @@ function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status Badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status, onChange }: { status: string; onChange: (s: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.new;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium border ${cfg.bg} ${cfg.color} whitespace-nowrap`}
|
||||
>
|
||||
{cfg.label}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 top-7 left-0 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[160px]">
|
||||
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { onChange(key); setOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-xs hover:bg-[#2e2e3e] ${c.color} ${status === key ? "font-bold" : ""}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SidePanel({ lead, onClose, onUpdate }: {
|
||||
function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
lead: Lead;
|
||||
onClose: () => void;
|
||||
onUpdate: (updated: Partial<Lead>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [notes, setNotes] = useState(lead.notes || "");
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [fullLead, setFullLead] = useState<Lead>(lead);
|
||||
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
|
||||
useEffect(() => {
|
||||
// Load full lead with events
|
||||
fetch(`/api/leads/${lead.id}`).then(r => r.json()).then(setFullLead).catch(() => {});
|
||||
}, [lead.id]);
|
||||
|
||||
const tags: string[] = JSON.parse(fullLead.tags || "[]");
|
||||
|
||||
async function patch(data: Partial<Lead>) {
|
||||
const res = await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json() as Lead;
|
||||
setFullLead(prev => ({ ...prev, ...updated }));
|
||||
onUpdate(updated);
|
||||
}
|
||||
function copy(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} kopiert`);
|
||||
}
|
||||
|
||||
function handleNotesChange(v: string) {
|
||||
setNotes(v);
|
||||
if (notesTimer.current) clearTimeout(notesTimer.current);
|
||||
notesTimer.current = setTimeout(() => patch({ notes: v }), 500);
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim().toLowerCase();
|
||||
if (!trimmed || tags.includes(trimmed)) return;
|
||||
const newTags = [...tags, trimmed];
|
||||
patch({ tags: JSON.stringify(newTags) });
|
||||
setTagInput("");
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
patch({ tags: JSON.stringify(tags.filter(t => t !== tag)) });
|
||||
}
|
||||
|
||||
const conf = fullLead.emailConfidence;
|
||||
const confColor = conf == null ? "text-gray-500" : conf >= 0.8 ? "text-green-400" : conf >= 0.5 ? "text-yellow-400" : "text-red-400";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative z-50 w-full max-w-md h-full bg-[#0e0e1a] border-l border-[#1e1e2e] overflow-y-auto flex flex-col"
|
||||
className="relative z-50 w-full max-w-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden"
|
||||
style={{ animation: "slideIn 200ms ease-out" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-[#0e0e1a] border-b border-[#1e1e2e] p-4 flex items-start gap-3">
|
||||
<div className="border-b border-[#1e1e2e] p-4 flex items-start gap-3 flex-shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-bold text-white truncate">{fullLead.companyName || fullLead.domain || "Unbekannt"}</h2>
|
||||
<p className="text-xs text-gray-400">{fullLead.domain}</p>
|
||||
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline flex items-center gap-1 mt-0.5">
|
||||
<Globe className="w-3 h-3" />{lead.domain}
|
||||
</a>
|
||||
)}
|
||||
{lead.industry && <p className="text-xs text-purple-400 mt-0.5">{lead.industry}</p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded">
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4 flex-1">
|
||||
{/* Status + Priority */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={fullLead.status} onChange={s => patch({ status: s })} />
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => patch({ priority: key })}
|
||||
className={`px-2 py-0.5 rounded text-xs border transition-all ${
|
||||
fullLead.priority === key
|
||||
? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color
|
||||
: "border-[#1e1e2e] text-gray-600 hover:border-[#2e2e3e]"
|
||||
}`}
|
||||
>
|
||||
{p.icon} {p.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
|
||||
{/* Description */}
|
||||
{lead.description && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</p>
|
||||
<p className="text-xs text-gray-300 leading-relaxed">{lead.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Kontakt</h3>
|
||||
{fullLead.contactName && <p className="text-sm text-white">{fullLead.contactName}{fullLead.contactTitle && <span className="text-gray-400 ml-1">· {fullLead.contactTitle}</span>}</p>}
|
||||
{fullLead.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className={`text-sm font-mono ${confColor}`}>{fullLead.email}</span>
|
||||
<button onClick={() => { navigator.clipboard.writeText(fullLead.email!); toast.success("Kopiert"); }} className="text-gray-600 hover:text-white">
|
||||
<FileText className="w-3 h-3" />
|
||||
</button>
|
||||
{conf != null && <span className={`text-xs ${confColor}`}>{Math.round(conf * 100)}%</span>}
|
||||
</div>
|
||||
)}
|
||||
{fullLead.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5 text-gray-500" />
|
||||
<a href={`tel:${fullLead.phone}`} className="text-sm text-gray-300 hover:text-white">{fullLead.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
{fullLead.address && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-500 mt-0.5">📍</span>
|
||||
<span className="text-sm text-gray-300">{fullLead.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{fullLead.linkedinUrl && (
|
||||
<a href={fullLead.linkedinUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-xs text-blue-400 hover:underline">
|
||||
<ExternalLink className="w-3 h-3" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source Info */}
|
||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-1.5">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Quelle</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
{SOURCE_CONFIG[fullLead.sourceTab]?.icon} {SOURCE_CONFIG[fullLead.sourceTab]?.label || fullLead.sourceTab}
|
||||
</p>
|
||||
{fullLead.sourceTerm && <p className="text-xs text-gray-500">Suche: <span className="text-gray-300">{fullLead.sourceTerm}</span></p>}
|
||||
<p className="text-xs text-gray-500">Erfasst: {new Date(fullLead.capturedAt).toLocaleString("de-DE")}</p>
|
||||
{fullLead.serpRank && <p className="text-xs text-gray-500">SERP Rang: #{fullLead.serpRank}</p>}
|
||||
{fullLead.serpUrl && <a href={fullLead.serpUrl} target="_blank" rel="noreferrer" className="text-xs text-blue-400 hover:underline flex items-center gap-1"><ExternalLink className="w-3 h-3" />URL öffnen</a>}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="hover:text-white"><X className="w-2.5 h-2.5" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{TAG_PRESETS.filter(t => !tags.includes(t)).slice(0, 5).map(t => (
|
||||
<button key={t} onClick={() => addTag(t)} className="text-[10px] px-1.5 py-0.5 rounded border border-[#2e2e3e] text-gray-600 hover:text-gray-300 hover:border-[#4e4e6e]">
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); addTag(tagInput); } }}
|
||||
placeholder="Tag hinzufügen..."
|
||||
className="flex-1 bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1 text-xs text-white outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button onClick={() => addTag(tagInput)} className="text-xs px-2 py-1 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Notizen</h3>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => handleNotesChange(e.target.value)}
|
||||
placeholder="Notizen hier eingeben..."
|
||||
rows={4}
|
||||
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1.5 text-sm text-white outline-none focus:border-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{fullLead.events && fullLead.events.length > 0 && (
|
||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Verlauf</h3>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2 text-xs text-gray-500">
|
||||
<span className="mt-0.5">📥</span>
|
||||
<div>
|
||||
<span className="text-gray-400">Erfasst</span>
|
||||
<span className="ml-2">{new Date(fullLead.capturedAt).toLocaleString("de-DE")}</span>
|
||||
{/* Contact */}
|
||||
{(lead.contactName || lead.email || lead.phone || lead.address || lead.linkedinUrl) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Kontakt</p>
|
||||
<div className="space-y-2">
|
||||
{lead.contactName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<span className="text-sm text-white">{lead.contactName}
|
||||
{lead.contactTitle && <span className="text-gray-500 ml-1 text-xs">· {lead.contactTitle}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{fullLead.events.map(ev => (
|
||||
<div key={ev.id} className="flex items-start gap-2 text-xs text-gray-500">
|
||||
<span className="mt-0.5">✏️</span>
|
||||
<div>
|
||||
<span className="text-gray-400">{ev.event}</span>
|
||||
<span className="ml-2">{new Date(ev.at).toLocaleString("de-DE")}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<span className="text-xs font-mono text-green-400 truncate flex-1">{lead.email}</span>
|
||||
<button onClick={() => copy(lead.email!, "E-Mail")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<a href={`tel:${lead.phone}`} className="text-xs text-gray-300 hover:text-white">{lead.phone}</a>
|
||||
<button onClick={() => copy(lead.phone!, "Telefon")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{lead.address && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-gray-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-gray-300 flex-1">{lead.address}</span>
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(lead.address)}`}
|
||||
target="_blank" rel="noreferrer"
|
||||
className="text-gray-600 hover:text-green-400 flex-shrink-0"
|
||||
title="In Google Maps öffnen"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{lead.linkedinUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
<a href={lead.linkedinUrl} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline truncate">
|
||||
LinkedIn Profil
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
{(lead.headcount || lead.country) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Unternehmen</p>
|
||||
<div className="flex gap-3 text-xs text-gray-400">
|
||||
{lead.headcount && <span>👥 {lead.headcount} Mitarbeiter</span>}
|
||||
{lead.country && <span>🌍 {lead.country}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Quelle</p>
|
||||
<div className="space-y-1 text-xs text-gray-400">
|
||||
<p>{src?.icon} {src?.label || lead.sourceTab}</p>
|
||||
{lead.sourceTerm && (
|
||||
<p>Suche: <span className="text-gray-300">"{lead.sourceTerm}"</span></p>
|
||||
)}
|
||||
<p>Erfasst: <span className="text-gray-300">{new Date(lead.capturedAt).toLocaleString("de-DE")}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags (read-only) */}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className={`px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="p-4 border-t border-[#1e1e2e] flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { onDelete(lead.id); onClose(); }}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 text-sm transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Lead löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -385,8 +287,9 @@ export default function LeadVaultPage() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string[]>([]);
|
||||
const [filterSource, setFilterSource] = useState<string[]>([]);
|
||||
const [filterPriority, setFilterPriority] = useState<string[]>([]);
|
||||
const [filterHasEmail, setFilterHasEmail] = useState("");
|
||||
const [filterContacted, setFilterContacted] = useState(false);
|
||||
const [filterFavorite, setFilterFavorite] = useState(false);
|
||||
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
|
||||
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
|
||||
|
||||
@@ -404,8 +307,10 @@ export default function LeadVaultPage() {
|
||||
// Side panel
|
||||
const [panelLead, setPanelLead] = useState<Lead | null>(null);
|
||||
|
||||
// Export dropdown
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
// Bulk
|
||||
const [bulkStatus, setBulkStatus] = useState("");
|
||||
const [bulkTag, setBulkTag] = useState("");
|
||||
|
||||
// Debounce search
|
||||
@@ -428,8 +333,9 @@ export default function LeadVaultPage() {
|
||||
});
|
||||
filterStatus.forEach(s => params.append("status", s));
|
||||
filterSource.forEach(s => params.append("sourceTab", s));
|
||||
filterPriority.forEach(p => params.append("priority", p));
|
||||
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
||||
if (filterContacted) params.set("contacted", "yes");
|
||||
if (filterFavorite) params.set("favorite", "yes");
|
||||
filterSearchTerms.forEach(t => params.append("searchTerm", t));
|
||||
|
||||
const res = await fetch(`/api/leads?${params}`);
|
||||
@@ -442,7 +348,7 @@ export default function LeadVaultPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterPriority, filterHasEmail, filterSearchTerms]);
|
||||
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterHasEmail, filterContacted, filterFavorite, filterSearchTerms]);
|
||||
|
||||
const loadSearchTerms = useCallback(async () => {
|
||||
const res = await fetch("/api/leads/search-terms");
|
||||
@@ -466,7 +372,8 @@ export default function LeadVaultPage() {
|
||||
|
||||
function clearFilters() {
|
||||
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
||||
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerms([]);
|
||||
setFilterHasEmail(""); setFilterContacted(false); setFilterFavorite(false);
|
||||
setFilterSearchTerms([]);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
@@ -551,7 +458,7 @@ export default function LeadVaultPage() {
|
||||
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-[1400px]">
|
||||
<div className="space-y-5 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
|
||||
@@ -561,35 +468,38 @@ export default function LeadVaultPage() {
|
||||
<Database className="w-4 h-4" />
|
||||
<span>Zentrale Datenbank</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">🗄️ LeadVault</h1>
|
||||
<h1 className="text-2xl font-bold text-white">🗄️ Leadspeicher</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">Alle Leads aus allen Pipelines an einem Ort.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { loadLeads(); loadStats(); }} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#2e2e3e] text-gray-400 hover:text-white hover:border-[#4e4e6e] text-sm transition-all">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Aktualisieren
|
||||
<button
|
||||
onClick={() => setExportOpen(v => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-purple-300 hover:bg-purple-500/20 text-sm transition-all"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-purple-300 hover:bg-purple-500/20 text-sm transition-all">
|
||||
<Download className="w-3.5 h-3.5" /> Export
|
||||
</button>
|
||||
<div className="absolute right-0 top-9 hidden group-hover:block z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[220px]">
|
||||
{[
|
||||
["Aktuelle Ansicht (CSV)", () => exportFile("csv")],
|
||||
["Aktuelle Ansicht (Excel)", () => exportFile("xlsx")],
|
||||
["Nur mit E-Mail (CSV)", () => exportFile("csv", true)],
|
||||
["Nur mit E-Mail (Excel)", () => exportFile("xlsx", true)],
|
||||
].map(([label, fn]) => (
|
||||
<button key={label as string} onClick={fn as () => void}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||
{label as string}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export dropdown — outside overflow-hidden header */}
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
|
||||
<div className="fixed top-20 right-6 z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-2xl p-1 min-w-[230px]">
|
||||
{([
|
||||
["Aktuelle Ansicht", () => exportFile("xlsx")],
|
||||
["Nur mit E-Mail", () => exportFile("xlsx", true)],
|
||||
] as [string, () => void][]).map(([label, fn]) => (
|
||||
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
@@ -616,7 +526,7 @@ export default function LeadVaultPage() {
|
||||
onClick={() => setSerpOpen(!serpOpen)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" />⚡ Quick SERP Capture</span>
|
||||
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" />⚡ Schnell neue Suche</span>
|
||||
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
{serpOpen && (
|
||||
@@ -705,9 +615,33 @@ export default function LeadVaultPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Kontaktiert toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterContacted(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterContacted
|
||||
? "bg-teal-500/20 text-teal-300 border-teal-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
>
|
||||
Kontaktiert
|
||||
</button>
|
||||
|
||||
{/* Favoriten toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterFavorite
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
>
|
||||
★ Favoriten
|
||||
</button>
|
||||
|
||||
{/* Clear + count */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerms.length) && (
|
||||
{(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-white flex items-center gap-1">
|
||||
<X className="w-3 h-3" /> Filter zurücksetzen
|
||||
</button>
|
||||
@@ -716,61 +650,6 @@ export default function LeadVaultPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
|
||||
<button key={key} onClick={() => toggleFilter(filterStatus, setFilterStatus, key)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterStatus.includes(key) ? c.bg + " " + c.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-[#2e2e3e] mx-1" />
|
||||
|
||||
{/* Source filter */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(SOURCE_CONFIG).map(([key, s]) => (
|
||||
<button key={key} onClick={() => toggleFilter(filterSource, setFilterSource, key)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterSource.includes(key) ? "bg-purple-500/20 text-purple-300 border-purple-500/30" : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
|
||||
{s.icon} {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-[#2e2e3e] mx-1" />
|
||||
|
||||
{/* Priority filter */}
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
|
||||
<button key={key} onClick={() => toggleFilter(filterPriority, setFilterPriority, key)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterPriority.includes(key) ? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
|
||||
{p.icon} {p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search term filter chips */}
|
||||
{availableSearchTerms.length > 0 && (
|
||||
<>
|
||||
<div className="w-px bg-[#2e2e3e] mx-1" />
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-1">Suchbegriff</span>
|
||||
{availableSearchTerms.map(term => (
|
||||
<button key={term} onClick={() => toggleFilter(filterSearchTerms, setFilterSearchTerms, term)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${
|
||||
filterSearchTerms.includes(term)
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "border-[#2e2e3e] text-gray-600 hover:text-gray-400"
|
||||
}`}>
|
||||
🔎 {term}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
@@ -778,14 +657,6 @@ export default function LeadVaultPage() {
|
||||
<div className="flex items-center gap-3 bg-purple-500/10 border border-purple-500/20 rounded-xl px-4 py-2.5">
|
||||
<span className="text-sm text-purple-300 font-medium">{selected.size} ausgewählt</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select
|
||||
value={bulkStatus}
|
||||
onChange={e => { setBulkStatus(e.target.value); if (e.target.value) bulkAction("status", e.target.value); }}
|
||||
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
|
||||
>
|
||||
<option value="">Status ändern...</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([k, c]) => <option key={k} value={k}>{c.label}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={bulkTag}
|
||||
@@ -824,8 +695,6 @@ export default function LeadVaultPage() {
|
||||
</button>
|
||||
</th>
|
||||
{[
|
||||
["status", "Status"],
|
||||
["priority", "Priorität"],
|
||||
["companyName", "Unternehmen"],
|
||||
["contactName", "Kontakt"],
|
||||
["phone", "Telefon"],
|
||||
@@ -846,17 +715,17 @@ export default function LeadVaultPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !leads.length ? (
|
||||
<tr><td colSpan={10} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
||||
) : leads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-16 text-center">
|
||||
<td colSpan={8} className="px-4 py-16 text-center">
|
||||
<Database className="w-8 h-8 text-gray-700 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">
|
||||
{(search || filterStatus.length || filterSource.length)
|
||||
{(search || filterSource.length || filterContacted || filterFavorite)
|
||||
? "Keine Leads für diese Filter."
|
||||
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
|
||||
</p>
|
||||
{(search || filterStatus.length) && (
|
||||
{search && (
|
||||
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -864,10 +733,10 @@ export default function LeadVaultPage() {
|
||||
) : leads.map((lead, i) => {
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const isSelected = selected.has(lead.id);
|
||||
const isHigh = lead.priority === "high";
|
||||
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
|
||||
const prio = PRIORITY_CONFIG[lead.priority] || PRIORITY_CONFIG.normal;
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
const isFavorite = tags.includes("favorit");
|
||||
const isContacted = tags.includes("kontaktiert");
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -875,7 +744,7 @@ export default function LeadVaultPage() {
|
||||
onClick={() => setPanelLead(lead)}
|
||||
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
|
||||
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]"
|
||||
} ${isHigh ? "border-l-2 border-l-red-500" : ""}`}
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => {
|
||||
@@ -890,15 +759,14 @@ export default function LeadVaultPage() {
|
||||
: <Square className="w-4 h-4 text-gray-600" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<StatusBadge status={lead.status} onChange={s => updateLead(lead.id, { status: s })} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`text-xs font-bold ${prio.color}`}>{prio.icon}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[180px]">
|
||||
<td className="px-3 py-2.5 max-w-[220px]">
|
||||
<p className="text-sm text-white truncate font-medium">{lead.companyName || "–"}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
|
||||
{(lead.description || lead.industry) && (
|
||||
<p className="text-[10px] text-gray-600 truncate mt-0.5" title={lead.description || lead.industry || ""}>
|
||||
{lead.industry ? `${lead.industry}${lead.description ? " · " + lead.description : ""}` : lead.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[160px]">
|
||||
<p className="text-sm text-gray-300 truncate">{lead.contactName || "–"}</p>
|
||||
@@ -931,14 +799,6 @@ export default function LeadVaultPage() {
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">–</span>
|
||||
)}
|
||||
{lead.emailConfidence != null && lead.email && (
|
||||
<div className="w-16 h-1 bg-[#2e2e3e] rounded-full mt-1">
|
||||
<div
|
||||
className={`h-full rounded-full ${lead.emailConfidence >= 0.8 ? "bg-green-500" : lead.emailConfidence >= 0.5 ? "bg-yellow-500" : "bg-red-500"}`}
|
||||
style={{ width: `${lead.emailConfidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 max-w-[160px]">
|
||||
{lead.sourceTerm ? (
|
||||
@@ -961,7 +821,7 @@ export default function LeadVaultPage() {
|
||||
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
|
||||
<span className="text-xs text-gray-500">{relativeTime(lead.capturedAt)}</span>
|
||||
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
||||
@@ -973,21 +833,39 @@ export default function LeadVaultPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button onClick={() => updateLead(lead.id, { status: "contacted" })}
|
||||
title="Als kontaktiert markieren"
|
||||
className="p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (isContacted) {
|
||||
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "kontaktiert")) });
|
||||
} else {
|
||||
updateLead(lead.id, { tags: JSON.stringify([...tags, "kontaktiert"]) });
|
||||
}
|
||||
}}
|
||||
title="Kontaktiert"
|
||||
className={isContacted
|
||||
? "p-1 rounded text-teal-400 bg-teal-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all"}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const cycle: Record<string, string> = { low: "normal", normal: "high", high: "low" };
|
||||
updateLead(lead.id, { priority: cycle[lead.priority] || "normal" });
|
||||
}}
|
||||
title="Priorität wechseln"
|
||||
className="p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite) {
|
||||
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "favorit")) });
|
||||
} else {
|
||||
updateLead(lead.id, { tags: JSON.stringify([...tags, "favorit"]) });
|
||||
}
|
||||
}}
|
||||
title="Favorit"
|
||||
className={isFavorite
|
||||
? "p-1 rounded text-amber-400 bg-amber-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
title="Website öffnen"
|
||||
className="p-1 rounded text-gray-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
@@ -1053,8 +931,10 @@ export default function LeadVaultPage() {
|
||||
lead={panelLead}
|
||||
onClose={() => setPanelLead(null)}
|
||||
onUpdate={updated => {
|
||||
setPanelLead(prev => prev ? { ...prev, ...updated } : prev);
|
||||
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
|
||||
}}
|
||||
onDelete={id => { deleteLead(id); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user