Integrate local business workflow and SaaS redesign
This commit is contained in:
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Activity, Files, SquarePen } from "lucide-react";
|
||||
import { Activity, Files, FileSearch, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -80,9 +80,9 @@ function getStageLabel(stage: string) {
|
||||
function AuditsBoardLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -149,12 +149,12 @@ export function AuditsBoard() {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<CardDescription>
|
||||
@@ -169,16 +169,20 @@ export function AuditsBoard() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Evidence Dossier</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Laufende Generierungen, veröffentlichbare Audits und Fehlerzustände
|
||||
als Prüfmappe statt lose Datensatzliste.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
||||
{auditStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
className="agency-tab"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
@@ -199,14 +203,15 @@ export function AuditsBoard() {
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={rowTitleId}
|
||||
className="flex min-w-0 flex-col"
|
||||
className="agency-panel flex min-w-0 flex-col overflow-hidden"
|
||||
key={row.id}
|
||||
>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardDescription>
|
||||
{row.kind === "audit" ? "Audit" : "Pipeline"}
|
||||
<CardDescription className="inline-flex items-center gap-2">
|
||||
<FileSearch className="size-3.5" aria-hidden="true" />
|
||||
{row.kind === "audit" ? "Audit Evidence" : "Pipeline Evidence"}
|
||||
</CardDescription>
|
||||
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
||||
{row.title}
|
||||
@@ -222,11 +227,11 @@ export function AuditsBoard() {
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="evidence-surface min-w-0 rounded-md px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Domain</p>
|
||||
<p className="mt-1 break-all">{row.checkedDomain}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{row.kind === "audit" ? "Seiten" : "Phase"}
|
||||
</p>
|
||||
@@ -244,14 +249,14 @@ export function AuditsBoard() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 rounded-md bg-muted/45 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Slug</p>
|
||||
<p className="mt-1 break-words text-muted-foreground">
|
||||
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
|
||||
</p>
|
||||
</div>
|
||||
{row.kind === "generation" && row.errorSummary ? (
|
||||
<p className="break-words rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<p className="break-words rounded-md border border-destructive/30 bg-[var(--danger-soft)] px-3 py-2 text-xs text-destructive">
|
||||
{row.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -260,7 +265,7 @@ export function AuditsBoard() {
|
||||
<div className="mt-auto flex justify-end">
|
||||
{row.kind === "audit" ? (
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm font-semibold text-primary hover:bg-muted"
|
||||
href={row.detailHref}
|
||||
>
|
||||
<SquarePen className="size-4" aria-hidden="true" />
|
||||
@@ -277,7 +282,7 @@ export function AuditsBoard() {
|
||||
);
|
||||
})}
|
||||
{visibleRows.length === 0 ? (
|
||||
<Card className="sm:col-span-2 xl:col-span-3">
|
||||
<Card className="agency-panel sm:col-span-2 xl:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Treffer</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
"use client"
|
||||
"use client";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileSearch,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const authSignals: Array<{
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}> = [
|
||||
{
|
||||
icon: FileSearch,
|
||||
label: "Evidence",
|
||||
value: "Audit-Aussagen bleiben belegbar.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
label: "Approval",
|
||||
value: "Public Audit und Mail bleiben getrennt.",
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
label: "Safety",
|
||||
value: "Versand erfolgt erst nach Finalbestätigung.",
|
||||
},
|
||||
];
|
||||
|
||||
export function AuthEntry() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -37,34 +66,42 @@ export function AuthEntry() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-10">
|
||||
<section className="grid w-full max-w-5xl overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="flex min-h-[520px] flex-col justify-between border-b p-6 md:border-b-0 md:border-r lg:p-8">
|
||||
<main className="dashboard-canvas flex min-h-dvh items-center justify-center px-6 py-10">
|
||||
<section className="agency-panel grid w-full max-w-5xl overflow-hidden text-card-foreground md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="flex min-h-[520px] flex-col justify-between border-b border-border/75 p-6 md:border-b-0 md:border-r lg:p-8">
|
||||
<div>
|
||||
<div className="mb-8 inline-flex size-10 items-center justify-center rounded-lg border bg-background">
|
||||
<LockKeyhole className="size-5" />
|
||||
<div className="mb-8 inline-flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-primary font-heading text-sm font-black text-primary-foreground">
|
||||
WP
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
WebDev Pipeline MVP
|
||||
<p className="agency-kicker">
|
||||
SaaS Workspace
|
||||
</p>
|
||||
<h1 className="mt-4 max-w-xl text-3xl font-semibold tracking-normal sm:text-4xl">
|
||||
Lokale Webdesign-Leads recherchieren, auditieren und freigeben.
|
||||
<h1 className="mt-4 max-w-xl font-heading text-3xl font-semibold tracking-normal sm:text-4xl">
|
||||
Lokale Chancen belegen, prüfen und erst dann kontaktieren.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-lg text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
Melde dich mit dem Admin-Konto an, um Kampagnen, Lead-Qualitaet,
|
||||
Audit-Freigaben und Outreach-Schritte in einem Arbeitsbereich zu
|
||||
steuern.
|
||||
Melde dich an, um Kampagnen, Lead-Qualität, Audit-Evidence und
|
||||
Outreach-Freigaben in einem geschützten Kunden-Workspace zu steuern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
|
||||
{[
|
||||
["Recherche", "Google Places Quellen und Kontaktluecken."],
|
||||
["Audit", "Website-Potenzial und Review-Status."],
|
||||
["Outreach", "Manuelle Freigabe vor Versand."],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-sm font-medium">{label}</dt>
|
||||
{authSignals.map(({ icon: Icon, label, value }) => (
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={label}>
|
||||
<dt className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
{value}
|
||||
</dd>
|
||||
@@ -73,10 +110,10 @@ export function AuthEntry() {
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center p-6 lg:p-8">
|
||||
<div className="flex flex-col justify-center bg-background/45 p-6 lg:p-8">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<h2 className="text-2xl font-semibold tracking-normal">
|
||||
Admin Login
|
||||
<h2 className="font-heading text-2xl font-semibold tracking-normal">
|
||||
Workspace Login
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Melde dich mit E-Mail und Passwort an.
|
||||
@@ -88,7 +125,7 @@ export function AuthEntry() {
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="admin@firma.de"
|
||||
@@ -99,7 +136,7 @@ export function AuthEntry() {
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
minLength={8}
|
||||
|
||||
@@ -20,17 +20,22 @@ type BlacklistType =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "company"
|
||||
| "google_place_id";
|
||||
| "google_place_id"
|
||||
| "source_business_id";
|
||||
|
||||
const blacklistTypeOptions: BlacklistType[] = [
|
||||
"domain",
|
||||
"email",
|
||||
"phone",
|
||||
"company",
|
||||
"source_business_id",
|
||||
"google_place_id",
|
||||
];
|
||||
|
||||
function labelForType(type: BlacklistType): string {
|
||||
if (type === "source_business_id") {
|
||||
return "Source Business ID";
|
||||
}
|
||||
if (type === "google_place_id") {
|
||||
return "Google Place ID";
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function CampaignFormDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
|
||||
Wähle Kategorie, PLZ, Radius und Lead-Limit je Kampagne.
|
||||
</DialogDescription>
|
||||
<DialogCloseButton />
|
||||
</DialogHeader>
|
||||
@@ -315,53 +315,28 @@ export function CampaignFormDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name="maxNewLeadsPerRun"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max. neue Leads</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
field.onChange(Number.isFinite(value) ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="maxAuditsPerRun"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max. Audits</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
field.onChange(Number.isFinite(value) ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={control}
|
||||
name="maxNewLeadsPerRun"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max. neue Leads</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
field.onChange(Number.isFinite(value) ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { MapPin, Pencil, Play, RefreshCcw, Plus } from "lucide-react";
|
||||
import { Clock3, MapPin, Pencil, Play, RefreshCcw, Plus, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -256,12 +256,16 @@ export function CampaignsBoard() {
|
||||
onSubmit={submitCampaign}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="agency-panel flex flex-col gap-4 p-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
|
||||
<p className="agency-kicker">Controlled Sourcing</p>
|
||||
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||
Kampagnen
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
|
||||
Prüfstationen vor Audit und Outreach.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
|
||||
@@ -275,7 +279,7 @@ export function CampaignsBoard() {
|
||||
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -289,7 +293,11 @@ export function CampaignsBoard() {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
|
||||
return (
|
||||
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
|
||||
<Card
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="agency-panel overflow-hidden"
|
||||
key={campaign._id}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
@@ -308,23 +316,26 @@ export function CampaignsBoard() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="evidence-surface flex flex-wrap items-center justify-between gap-3 rounded-md px-3 py-2">
|
||||
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
<span>{campaign.postalCode}</span>
|
||||
</div>
|
||||
<span>{campaign.radiusKm} km</span>
|
||||
<span className="font-semibold">{campaign.radiusKm} km</span>
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div>
|
||||
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
|
||||
<p>
|
||||
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
|
||||
{campaign.maxAuditsPerRun}
|
||||
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
Cadence: {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
|
||||
<p className="text-muted-foreground">
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
@@ -373,7 +384,7 @@ export function CampaignsBoard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -387,7 +398,7 @@ export function CampaignsBoard() {
|
||||
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
||||
) : (
|
||||
visibleRuns.map((run) => (
|
||||
<div className="rounded-md border p-3" key={run._id}>
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={run._id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{statusLabel[run.status] ?? run.status}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { CheckCircle2, LogOut, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,21 +20,41 @@ export function DashboardSidebar() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<aside className="flex w-full shrink-0 flex-col border-b bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-72 md:border-b-0 md:border-r">
|
||||
<div className="flex h-16 items-center gap-3 border-b px-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
W
|
||||
<aside className="flex w-full shrink-0 flex-col border-b border-sidebar-border bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-[19rem] md:border-b-0 md:border-r">
|
||||
<div className="flex h-[5.25rem] items-center gap-3 border-b border-sidebar-border px-4">
|
||||
<div className="flex size-11 items-center justify-center rounded-md bg-sidebar-primary font-heading text-sm font-black text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_75%)]">
|
||||
WP
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">WebDev Pipeline</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Akquise Workspace
|
||||
<p className="truncate font-heading text-sm font-semibold">
|
||||
WebDev Pipeline
|
||||
</p>
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
Agency Evidence Desk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 border-b border-sidebar-border p-3">
|
||||
<div className="rounded-md border border-sidebar-border bg-sidebar-accent p-3">
|
||||
<p className="text-xs font-semibold text-sidebar-accent-foreground">
|
||||
Workspace Safety
|
||||
</p>
|
||||
<div className="mt-2 grid gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ShieldCheck className="size-3.5 text-sidebar-primary" />
|
||||
Versand nur nach Freigabe
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CheckCircle2 className="size-3.5 text-sidebar-primary" />
|
||||
Evidence vor Outreach
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="flex gap-1 overflow-x-auto p-3 md:grid md:overflow-visible"
|
||||
className="flex gap-1 overflow-x-auto p-3 md:grid md:gap-1.5 md:overflow-visible"
|
||||
aria-label="Dashboard navigation"
|
||||
>
|
||||
{dashboardNavigation.map((item) => {
|
||||
@@ -48,9 +68,9 @@ export function DashboardSidebar() {
|
||||
<Link
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"flex h-10 shrink-0 items-center gap-2 rounded-md px-3 text-sm font-semibold outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/35",
|
||||
isActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_82%)]"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
href={item.href}
|
||||
@@ -64,13 +84,13 @@ export function DashboardSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-3 md:mt-auto">
|
||||
<div className="mb-3 rounded-lg border bg-background p-3 md:block">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{isPending ? "Lade..." : session?.user?.name ?? "Admin"}
|
||||
<div className="border-t border-sidebar-border p-3 md:mt-auto">
|
||||
<div className="mb-3 rounded-md border border-sidebar-border bg-sidebar-accent p-3 md:block">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{isPending ? "Lade..." : session?.user?.name ?? "Workspace"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{session?.user?.email ?? "admin@local"}
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
{session?.user?.email ?? "team@workspace"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function DashboardThemeProvider({ children }: { children: ReactNode }) {
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-dvh bg-background text-foreground md:flex",
|
||||
"dashboard-canvas min-h-dvh text-foreground md:flex",
|
||||
theme === "dark" && "dark",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { ArrowRight, Building2, MapPin } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileSearch,
|
||||
MapPin,
|
||||
ShieldAlert,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -24,6 +33,42 @@ const stageActionHref: Record<LeadFunnelStageId, string> = {
|
||||
deferred: "/dashboard/leads",
|
||||
};
|
||||
|
||||
const stageVisuals: Record<
|
||||
LeadFunnelStageId,
|
||||
{ surface: string; label: string; icon: LucideIcon }
|
||||
> = {
|
||||
missing_contact: {
|
||||
surface: "review-surface",
|
||||
label: "Kontakt klären",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
audit_ready: {
|
||||
surface: "evidence-surface",
|
||||
label: "Evidence sammeln",
|
||||
icon: FileSearch,
|
||||
},
|
||||
review_open: {
|
||||
surface: "review-surface",
|
||||
label: "Freigabe prüfen",
|
||||
icon: Clock3,
|
||||
},
|
||||
contacted: {
|
||||
surface: "safe-surface",
|
||||
label: "Kontakt läuft",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
follow_up: {
|
||||
surface: "safe-surface",
|
||||
label: "Follow-up",
|
||||
icon: Clock3,
|
||||
},
|
||||
deferred: {
|
||||
surface: "bg-[var(--danger-soft)] text-destructive",
|
||||
label: "Zurückgestellt",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
};
|
||||
|
||||
export function LeadFunnelBoard() {
|
||||
const leads: LeadFunnelQueryResult | undefined = useQuery(
|
||||
api.leads.listFunnel,
|
||||
@@ -40,14 +85,14 @@ export function LeadFunnelBoard() {
|
||||
if (totalCards === 0) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-lg border bg-card p-6 text-card-foreground"
|
||||
className="rounded-lg border border-border/80 bg-card p-6 text-card-foreground"
|
||||
aria-labelledby="lead-funnel-heading"
|
||||
>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
Lead-Funnel
|
||||
</p>
|
||||
<h2
|
||||
className="mt-2 text-xl font-semibold tracking-normal"
|
||||
className="mt-2 font-heading text-xl font-semibold tracking-normal"
|
||||
id="lead-funnel-heading"
|
||||
>
|
||||
Noch keine Leads im Arbeitsfluss
|
||||
@@ -61,62 +106,85 @@ export function LeadFunnelBoard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-3" aria-labelledby="lead-funnel-heading">
|
||||
<section className="agency-panel p-4" aria-labelledby="lead-funnel-heading">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="agency-kicker">Lead Workflow</p>
|
||||
<h2
|
||||
className="text-xl font-semibold tracking-normal"
|
||||
className="mt-1 font-heading text-xl font-semibold tracking-normal"
|
||||
id="lead-funnel-heading"
|
||||
>
|
||||
Lead-Funnel
|
||||
Evidence Pipeline
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
|
||||
manueller Aktion.
|
||||
{totalCards} Leads nach nächster Entscheidung, Beleglage und
|
||||
Outreach-Sicherheit.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Kein automatischer Versand
|
||||
<p className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
|
||||
Human approval required
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
className="flex min-h-[24rem] flex-col rounded-lg border bg-card text-card-foreground"
|
||||
key={group.stage.id}
|
||||
aria-labelledby={`${group.stage.id}-heading`}
|
||||
>
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
id={`${group.stage.id}-heading`}
|
||||
>
|
||||
{group.stage.title}
|
||||
</h3>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{group.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{group.stage.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<LeadFunnelStageView group={group} key={group.stage.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="grid gap-2 p-2">
|
||||
{group.cards.length > 0 ? (
|
||||
group.cards.map((card) => (
|
||||
<LeadFunnelCardView card={card} key={card.id} />
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
|
||||
Keine Leads in dieser Spalte.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
function LeadFunnelStageView({
|
||||
group,
|
||||
}: {
|
||||
group: ReturnType<typeof groupLeadFunnelCards>[number];
|
||||
}) {
|
||||
const visual = stageVisuals[group.stage.id];
|
||||
const Icon = visual.icon;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-md border border-border/80 bg-background/55"
|
||||
aria-labelledby={`${group.stage.id}-heading`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/75 p-3">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<span
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-md ${visual.surface}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase text-muted-foreground">
|
||||
{visual.label}
|
||||
</p>
|
||||
<h3
|
||||
className="mt-1 text-sm font-semibold"
|
||||
id={`${group.stage.id}-heading`}
|
||||
>
|
||||
{group.stage.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{group.stage.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-card px-2 py-1 text-sm font-bold">
|
||||
{group.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 p-2">
|
||||
{group.cards.length > 0 ? (
|
||||
group.cards.map((card) => (
|
||||
<LeadFunnelCardView card={card} key={card.id} />
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed border-border/80 bg-card/50 p-3 text-xs leading-5 text-muted-foreground">
|
||||
Keine Leads in diesem Entscheidungsschritt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -125,7 +193,7 @@ export function LeadFunnelBoard() {
|
||||
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-background p-3"
|
||||
className="rounded-lg border border-border/80 bg-background/65 p-3"
|
||||
aria-labelledby={`${card.id}-company`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -143,7 +211,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-1 text-xs font-medium",
|
||||
"shrink-0 rounded-md px-2 py-1 text-xs font-semibold",
|
||||
card.priorityLabel === "Hoch"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
@@ -159,16 +227,16 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground">
|
||||
<span className="rounded-md bg-secondary px-2 py-1 text-xs font-semibold text-secondary-foreground">
|
||||
{card.contactStatusLabel}
|
||||
</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{card.contactDetail}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-medium text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-semibold text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/35"
|
||||
href={stageActionHref[card.stageId]}
|
||||
prefetch={false}
|
||||
>
|
||||
@@ -189,7 +257,7 @@ function LeadFunnelSkeleton() {
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div
|
||||
className="min-h-[24rem] rounded-lg border bg-card p-3"
|
||||
className="min-h-[24rem] rounded-lg border border-border/80 bg-card p-3"
|
||||
key={index}
|
||||
>
|
||||
<div className="h-5 w-28 rounded-md bg-muted" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Building2, Mail, MapPin, Phone, ShieldAlert } from "lucide-react";
|
||||
import { Building2, ExternalLink, Mail, MapPin, Phone, PlayCircle, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -39,6 +39,10 @@ import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
|
||||
type LeadRow = NonNullable<LeadsListResult>[number];
|
||||
type AuditStartStatesResult = FunctionReturnType<
|
||||
typeof api.pageSpeed.getLeadAuditStartStates
|
||||
>;
|
||||
type AuditStartState = NonNullable<AuditStartStatesResult>[number];
|
||||
|
||||
type LeadReviewDraft = {
|
||||
priority: LeadPriority;
|
||||
@@ -80,6 +84,33 @@ function normalizeTextInput(value: string): string | undefined {
|
||||
return next.length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function toEmailHref(email?: string | null): string | null {
|
||||
const normalizedEmail = normalizeTextInput(email ?? "");
|
||||
|
||||
return normalizedEmail ? `mailto:${normalizedEmail}` : null;
|
||||
}
|
||||
|
||||
function toPhoneHref(phone?: string | null): string | null {
|
||||
const normalizedPhone = normalizeTextInput(phone ?? "");
|
||||
const dialablePhone = normalizedPhone?.replace(/[^\d+]/g, "");
|
||||
|
||||
return dialablePhone ? `tel:${dialablePhone}` : null;
|
||||
}
|
||||
|
||||
function toWebsiteHref(lead: Pick<LeadRow, "websiteDomain" | "websiteUrl">): string | null {
|
||||
const website = normalizeTextInput(lead.websiteUrl ?? "") ?? normalizeTextInput(lead.websiteDomain ?? "");
|
||||
|
||||
if (!website) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(website)) {
|
||||
return website;
|
||||
}
|
||||
|
||||
return `https://${website.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function contactSourceLabel(lead: LeadRow): string {
|
||||
if (lead.sourceProvider) {
|
||||
return lead.sourceProvider;
|
||||
@@ -139,6 +170,45 @@ function duplicateBadgeVariant(
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function auditStartDisabledReason({
|
||||
lead,
|
||||
auditStartState,
|
||||
isLoading,
|
||||
isStarting,
|
||||
}: {
|
||||
lead: LeadRow;
|
||||
auditStartState?: AuditStartState;
|
||||
isLoading: boolean;
|
||||
isStarting: boolean;
|
||||
}) {
|
||||
if (isStarting) {
|
||||
return "Audit läuft";
|
||||
}
|
||||
|
||||
if (!lead.websiteUrl) {
|
||||
return "Keine Website hinterlegt.";
|
||||
}
|
||||
|
||||
if (
|
||||
lead.priority === "blocked" ||
|
||||
lead.priority === "defer" ||
|
||||
lead.blacklistStatus === "blocked" ||
|
||||
lead.contactStatus === "do_not_contact"
|
||||
) {
|
||||
return "Lead ist gesperrt oder zurückgestellt.";
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return "Audit-Status wird geladen.";
|
||||
}
|
||||
|
||||
if (auditStartState && !auditStartState.canStart) {
|
||||
return auditStartState.reason ?? "Audit kann aktuell nicht gestartet werden.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LeadsReviewTable() {
|
||||
const leads = useQuery(api.leads.list, { limit: 120 });
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
@@ -151,6 +221,21 @@ export function LeadsReviewTable() {
|
||||
|
||||
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [leads]);
|
||||
const auditStartStates = useQuery(
|
||||
api.pageSpeed.getLeadAuditStartStates,
|
||||
leads
|
||||
? {
|
||||
leadIds: sortedLeads.map((lead) => lead._id),
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
const auditStartStateByLeadId = useMemo(() => {
|
||||
const next = new Map<string, AuditStartState>();
|
||||
for (const state of auditStartStates ?? []) {
|
||||
next.set(state.leadId, state);
|
||||
}
|
||||
return next;
|
||||
}, [auditStartStates]);
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (activeFilter === "high") {
|
||||
return sortedLeads.filter((lead) => lead.priority === "high");
|
||||
@@ -178,16 +263,20 @@ export function LeadsReviewTable() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">Leads Review</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||
<div className="agency-panel mx-auto flex w-full max-w-7xl flex-col gap-2 p-4">
|
||||
<p className="agency-kicker">Lead Intake</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Kontaktlage, Sperrlisten, Duplikate und Audit-Start bleiben vor jedem
|
||||
Outreach als überprüfbare Entscheidungen sichtbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
|
||||
{leadStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
className="agency-tab"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
@@ -201,7 +290,7 @@ export function LeadsReviewTable() {
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||
{leads === undefined ? (
|
||||
Array.from({ length: 4 }, (_, index) => (
|
||||
<Card key={index}>
|
||||
<Card className="agency-panel" key={index}>
|
||||
<CardHeader>
|
||||
<div className="h-5 w-2/3 rounded-md bg-muted" />
|
||||
<div className="h-4 w-1/2 rounded-md bg-muted" />
|
||||
@@ -210,7 +299,7 @@ export function LeadsReviewTable() {
|
||||
</Card>
|
||||
))
|
||||
) : sortedLeads.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -219,7 +308,7 @@ export function LeadsReviewTable() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : filteredLeads.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Treffer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -232,6 +321,8 @@ export function LeadsReviewTable() {
|
||||
<LeadReviewRow
|
||||
key={lead._id}
|
||||
lead={lead}
|
||||
auditStartState={auditStartStateByLeadId.get(lead._id)}
|
||||
auditStartStateLoading={auditStartStates === undefined}
|
||||
onActionMessage={setActionMessage}
|
||||
/>
|
||||
))
|
||||
@@ -249,9 +340,13 @@ export function LeadsReviewTable() {
|
||||
|
||||
function LeadReviewRow({
|
||||
lead,
|
||||
auditStartState,
|
||||
auditStartStateLoading,
|
||||
onActionMessage,
|
||||
}: {
|
||||
lead: LeadRow;
|
||||
auditStartState?: AuditStartState;
|
||||
auditStartStateLoading: boolean;
|
||||
onActionMessage: (value: string) => void;
|
||||
}) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
@@ -270,16 +365,28 @@ function LeadReviewRow({
|
||||
}));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isBlocking, setIsBlocking] = useState(false);
|
||||
const [isStartingAudit, setIsStartingAudit] = useState(false);
|
||||
const [rowMessage, setRowMessage] = useState<string | null>(null);
|
||||
const reviewUpdate = useMutation(api.leads.reviewUpdate);
|
||||
const requestLeadAudit = useMutation(api.pageSpeed.requestLeadAudit);
|
||||
|
||||
const location = formatLocation(lead);
|
||||
const emailHref = toEmailHref(lead.email);
|
||||
const phoneHref = toPhoneHref(lead.phone);
|
||||
const websiteHref = toWebsiteHref(lead);
|
||||
const websiteLabel = lead.websiteDomain ?? lead.websiteUrl;
|
||||
const reasonParts = [
|
||||
lead.priorityReason,
|
||||
lead.contactStatusReason,
|
||||
lead.duplicateReason,
|
||||
lead.blacklistReason,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
const manualAuditDisabledReason = auditStartDisabledReason({
|
||||
lead,
|
||||
auditStartState,
|
||||
isLoading: auditStartStateLoading,
|
||||
isStarting: isStartingAudit,
|
||||
});
|
||||
|
||||
const update = async (
|
||||
payload?: Omit<LeadReviewPayload, "id">,
|
||||
@@ -342,6 +449,28 @@ function LeadReviewRow({
|
||||
setIsBlocking(false);
|
||||
};
|
||||
|
||||
const startAudit = async () => {
|
||||
if (manualAuditDisabledReason) {
|
||||
setRowMessage(manualAuditDisabledReason);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingAudit(true);
|
||||
setRowMessage(null);
|
||||
onActionMessage("");
|
||||
|
||||
try {
|
||||
const result = await requestLeadAudit({ leadId: lead._id });
|
||||
setRowMessage(result.message);
|
||||
onActionMessage(result.message);
|
||||
} catch {
|
||||
setRowMessage("Audit-Start fehlgeschlagen");
|
||||
} finally {
|
||||
setIsStartingAudit(false);
|
||||
setTimeout(() => setRowMessage(null), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = <T extends keyof LeadReviewDraft>(
|
||||
field: T,
|
||||
value: LeadReviewDraft[T],
|
||||
@@ -364,7 +493,7 @@ function LeadReviewRow({
|
||||
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
|
||||
|
||||
return (
|
||||
<Card aria-labelledby={titleId}>
|
||||
<Card aria-labelledby={titleId} className="agency-panel overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
@@ -398,27 +527,62 @@ function LeadReviewRow({
|
||||
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
|
||||
<p className="inline-flex min-w-0 items-center gap-1">
|
||||
<Mail className="size-3 shrink-0" />
|
||||
<span className="max-w-full min-w-0 break-all">
|
||||
{lead.email || "Keine E-Mail"}
|
||||
</span>
|
||||
{emailHref ? (
|
||||
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={emailHref}>
|
||||
{lead.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="max-w-full min-w-0 break-all">Keine E-Mail</span>
|
||||
)}
|
||||
</p>
|
||||
{lead.phone ? (
|
||||
{lead.phone && phoneHref ? (
|
||||
<p className="inline-flex min-w-0 items-center gap-1">
|
||||
<Phone className="size-3 shrink-0" />
|
||||
<span className="max-w-full min-w-0 break-all">{lead.phone}</span>
|
||||
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={phoneHref}>
|
||||
{lead.phone}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="truncate max-w-full">
|
||||
Quelle: {contactSourceLabel(lead)}
|
||||
</p>
|
||||
{lead.websiteDomain ? (
|
||||
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
|
||||
{websiteHref && websiteLabel ? (
|
||||
<p className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
<span className="shrink-0">Website:</span>
|
||||
<a
|
||||
className="min-w-0 truncate hover:text-foreground hover:underline"
|
||||
href={websiteHref}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{websiteLabel}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="border-t p-4 pt-3">
|
||||
<div className="flex flex-wrap gap-2 border-t bg-muted/25 p-4 pt-3">
|
||||
<div className="grid gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={startAudit}
|
||||
disabled={manualAuditDisabledReason !== null}
|
||||
size="sm"
|
||||
title={manualAuditDisabledReason ?? "Audit manuell starten"}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
Audit starten
|
||||
</Button>
|
||||
{manualAuditDisabledReason ? (
|
||||
<p className="max-w-56 text-xs text-muted-foreground">
|
||||
{manualAuditDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -4,7 +4,16 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
MailCheck,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -176,9 +185,9 @@ function FieldPair({ label, value }: { label: string; value?: string | null }) {
|
||||
function WorkspaceLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
</header>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
@@ -244,11 +253,11 @@ export function OutreachReviewWorkspace() {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<Card className="agency-panel">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium">Keine offenen Reviews</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -482,9 +491,9 @@ export function OutreachReviewWorkspace() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<header className="agency-panel space-y-2 p-4">
|
||||
<p className="agency-kicker">Approval Bench</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
|
||||
wird oder eine Freigabe erhält.
|
||||
@@ -492,7 +501,7 @@ export function OutreachReviewWorkspace() {
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
||||
<p className="rounded-md border border-border/75 bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
@@ -560,7 +569,7 @@ export function OutreachReviewWorkspace() {
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Senden
|
||||
Final senden
|
||||
</Button>
|
||||
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
|
||||
Abbrechen
|
||||
@@ -570,14 +579,15 @@ export function OutreachReviewWorkspace() {
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<section className="space-y-3" aria-label="Review-Queue">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(18rem,0.78fr)_minmax(0,1.22fr)]">
|
||||
<section className="agency-panel space-y-3 p-3" aria-label="Review-Queue">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Review-Queue</h2>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
|
||||
{reviewStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
className="agency-tab"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
@@ -589,7 +599,7 @@ export function OutreachReviewWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-3">
|
||||
{filteredRows.map((record) => {
|
||||
const lead = record.lead;
|
||||
const audit = record.audit;
|
||||
@@ -602,8 +612,10 @@ export function OutreachReviewWorkspace() {
|
||||
<Card
|
||||
aria-labelledby={queueTitleId}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col",
|
||||
selectedRecord?.id === record.id ? "border-foreground" : "",
|
||||
"flex min-w-0 flex-col overflow-hidden",
|
||||
selectedRecord?.id === record.id
|
||||
? "border-primary bg-[var(--surface-evidence)]"
|
||||
: "bg-background/60",
|
||||
)}
|
||||
key={record.id}
|
||||
>
|
||||
@@ -656,7 +668,7 @@ export function OutreachReviewWorkspace() {
|
||||
);
|
||||
})}
|
||||
{filteredRows.length === 0 ? (
|
||||
<Card className="lg:col-span-2 xl:col-span-3">
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Treffer</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -695,8 +707,8 @@ export function OutreachReviewWorkspace() {
|
||||
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden" key={record.id}>
|
||||
<CardHeader className="gap-3 border-b bg-muted/20 p-4">
|
||||
<Card className="agency-panel overflow-hidden" key={record.id}>
|
||||
<CardHeader className="gap-4 border-b bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="break-words text-lg">
|
||||
@@ -719,11 +731,49 @@ export function OutreachReviewWorkspace() {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div className="evidence-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<FileSearch className="size-3.5" />
|
||||
Evidence
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{audit ? "Audit vorhanden" : "Audit offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="review-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Public Audit
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{audit?.status === "published" ? "Veröffentlicht" : "Prüfung offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="safe-surface rounded-md px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<MailCheck className="size-3.5" />
|
||||
E-Mail
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{isEmailDraftReady(record) ? "Bereit" : "Entwurf offen"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/75 bg-background/70 px-3 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<CheckCircle2 className="size-3.5 text-primary" />
|
||||
Final Send
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{isQueuedSend ? "Wird gesendet" : "Bestätigung nötig"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Lead-Details</h2>
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldPair label="Nische" value={lead?.niche} />
|
||||
@@ -752,7 +802,7 @@ export function OutreachReviewWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
|
||||
{publicAuditHref ? (
|
||||
@@ -828,7 +878,7 @@ export function OutreachReviewWorkspace() {
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Empfohlene E-Mail</h2>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -877,12 +927,12 @@ export function OutreachReviewWorkspace() {
|
||||
type="button"
|
||||
>
|
||||
<MailCheck className="size-3.5" />
|
||||
E-Mail freigeben und senden
|
||||
E-Mail freigeben
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<h2 className="text-sm font-semibold">Telefon & Follow-up</h2>
|
||||
{hasCallablePhone ? (
|
||||
<label className="block space-y-1">
|
||||
@@ -997,6 +1047,7 @@ export function OutreachReviewWorkspace() {
|
||||
);
|
||||
})() : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-semibold",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
outline:
|
||||
"text-foreground border-border bg-background hover:bg-muted/40",
|
||||
"border-border/90 bg-background/70 text-foreground hover:bg-muted/50",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
},
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--primary-foreground),transparent_82%)] hover:bg-[color-mix(in_oklch,var(--primary),var(--foreground)_10%)]",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border/90 bg-background/70 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
|
||||
@@ -8,7 +8,10 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground", className)}
|
||||
className={cn(
|
||||
"rounded-lg border border-border/80 bg-card text-card-foreground shadow-[0_1px_0_color-mix(in_oklch,var(--foreground),transparent_94%)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -21,7 +24,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-4", className)}
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -33,7 +36,10 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-base leading-none font-semibold tracking-normal", className)}
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-semibold tracking-normal",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user