Integrate local business workflow and SaaS redesign
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user