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

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