Integrate local business workflow and SaaS redesign

This commit is contained in:
2026-06-12 21:08:35 +02:00
parent f00c5a3193
commit 21c7e4c9a4
88 changed files with 2683 additions and 849 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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";
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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">

View File

@@ -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",
)}
>

View File

@@ -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" />

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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",
},

View File

@@ -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:

View File

@@ -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}
/>
));