452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
|
|
import { useQuery } from "convex/react";
|
|
import type { Id } from "@/convex/_generated/dataModel";
|
|
import { api } from "@/convex/_generated/api";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Globe } from "lucide-react";
|
|
|
|
type UsedSkill = {
|
|
id?: string;
|
|
name: string;
|
|
purpose?: string;
|
|
category?: string;
|
|
source?: string;
|
|
version?: string;
|
|
};
|
|
|
|
type SkillSummary = {
|
|
name: string;
|
|
purpose: string;
|
|
summary: string;
|
|
};
|
|
|
|
type LeadContext = {
|
|
_id: Id<"leads">;
|
|
companyName?: string;
|
|
websiteDomain?: string;
|
|
websiteUrl?: string;
|
|
city?: string;
|
|
niche?: string;
|
|
};
|
|
|
|
type SkillAwareAudit = {
|
|
_id: Id<"audits">;
|
|
slug: string;
|
|
checkedDomain: string;
|
|
status: "draft" | "approved" | "published" | "deactivated";
|
|
checkedPages: string[];
|
|
createdAt?: number;
|
|
updatedAt?: number;
|
|
usedSkills?: UsedSkill[];
|
|
skillSummaries?: SkillSummary[];
|
|
internalSummary?: string | null;
|
|
};
|
|
|
|
type AuditFindingEvidenceRef = {
|
|
id: string;
|
|
type:
|
|
| "crawl_page"
|
|
| "technical_check"
|
|
| "screenshot"
|
|
| "pagespeed"
|
|
| "jina_excerpt"
|
|
| "generation_stage";
|
|
label: string;
|
|
sourceUrl?: string;
|
|
};
|
|
|
|
type AuditFinding = {
|
|
_id: string;
|
|
skillId: string;
|
|
claim: string;
|
|
recommendation: string;
|
|
customerBenefit: string;
|
|
severity: 1 | 2 | 3;
|
|
confidence: number;
|
|
evidenceRefs: AuditFindingEvidenceRef[];
|
|
reviewStatus: "pending" | "accepted" | "rejected";
|
|
};
|
|
|
|
type CheckedPageScreenshot = {
|
|
id: Id<"_storage">;
|
|
url: string;
|
|
viewport: "desktop" | "mobile";
|
|
sourceUrl: string;
|
|
width: number;
|
|
height: number;
|
|
createdAt: number;
|
|
};
|
|
|
|
type CheckedPageEvidence = {
|
|
url: string;
|
|
sourceUrl: string | null;
|
|
finalUrl: string | null;
|
|
pageKind: string | null;
|
|
title: string | null;
|
|
metaDescription: string | null;
|
|
headings: string[];
|
|
visibleTextExcerpt: string | null;
|
|
hasContactFormSignal: boolean | null;
|
|
hasContactCtaSignal: boolean | null;
|
|
usesHttps: boolean | null;
|
|
missingMetaDescription: boolean | null;
|
|
brokenInternalLinkCount: number | null;
|
|
screenshots: CheckedPageScreenshot[];
|
|
createdAt: number | null;
|
|
};
|
|
|
|
type AuditDetailResult = {
|
|
audit: SkillAwareAudit;
|
|
lead: LeadContext | null;
|
|
findings: AuditFinding[];
|
|
sourceSummaries: {
|
|
checkedPages: CheckedPageEvidence[];
|
|
};
|
|
} | null;
|
|
|
|
const statusText: Record<string, string> = {
|
|
draft: "Entwurf",
|
|
approved: "Freigegeben",
|
|
published: "Veröffentlicht",
|
|
deactivated: "Deaktiviert",
|
|
};
|
|
|
|
function getStatusLabel(status: SkillAwareAudit["status"]) {
|
|
return statusText[status] ?? "Unbekannt";
|
|
}
|
|
|
|
function getPageKindLabel(pageKind: string | null) {
|
|
const labels: Record<string, string> = {
|
|
contact: "Kontakt",
|
|
homepage: "Startseite",
|
|
imprint: "Impressum",
|
|
other: "Unterseite",
|
|
service: "Leistung",
|
|
};
|
|
|
|
return pageKind ? labels[pageKind] ?? pageKind : "Geprüft";
|
|
}
|
|
|
|
function signalText(value: boolean | null, positive: string, negative: string) {
|
|
if (value === null) {
|
|
return "Unbekannt";
|
|
}
|
|
|
|
return value ? positive : negative;
|
|
}
|
|
|
|
function metaSignalText(page: CheckedPageEvidence) {
|
|
if (page.metaDescription) {
|
|
return "Vorhanden";
|
|
}
|
|
|
|
if (page.missingMetaDescription === true) {
|
|
return "Fehlt";
|
|
}
|
|
|
|
if (page.missingMetaDescription === false) {
|
|
return "Vorhanden";
|
|
}
|
|
|
|
return "Unbekannt";
|
|
}
|
|
|
|
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
|
|
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
|
|
crawl_page: "Crawl",
|
|
technical_check: "Technik",
|
|
screenshot: "Screenshot",
|
|
pagespeed: "PageSpeed",
|
|
jina_excerpt: "Reader",
|
|
generation_stage: "KI-Stufe",
|
|
};
|
|
|
|
return labels[type] ?? type;
|
|
}
|
|
|
|
function leadSummary(lead: LeadContext | null | undefined) {
|
|
if (!lead) {
|
|
return "Kein Lead-Kontext gespeichert";
|
|
}
|
|
|
|
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
|
|
let leadDomain = lead.websiteDomain ?? "—";
|
|
|
|
if (!leadDomain && lead.websiteUrl) {
|
|
try {
|
|
leadDomain = new URL(lead.websiteUrl).hostname;
|
|
} catch {
|
|
leadDomain = lead.websiteUrl;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
|
|
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
|
|
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
|
<Globe className="size-3.5" />
|
|
{leadDomain}
|
|
</p>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
|
const result = useQuery(api.audits.getDetail, {
|
|
id: id as Id<"audits">,
|
|
}) as AuditDetailResult | undefined;
|
|
const audit = result?.audit;
|
|
const lead = result?.lead;
|
|
|
|
const usedSkills = useMemo(() => {
|
|
const summaries = audit?.skillSummaries ?? [];
|
|
return (audit?.usedSkills ?? []).map((skill) => {
|
|
const summary = summaries.find(
|
|
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
|
|
);
|
|
return {
|
|
...skill,
|
|
purpose: summary?.purpose ?? skill.purpose,
|
|
summary: summary?.summary,
|
|
};
|
|
});
|
|
}, [audit]);
|
|
const findings = useMemo(() => result?.findings ?? [], [result]);
|
|
const checkedPageEvidence = useMemo(
|
|
() => result?.sourceSummaries.checkedPages ?? [],
|
|
[result],
|
|
);
|
|
|
|
if (result === null) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Audit nicht gefunden</CardTitle>
|
|
<CardDescription>
|
|
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (audit === undefined) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Audit wird geladen...</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardDescription>Audit-Detail</CardDescription>
|
|
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
|
|
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
|
|
<Globe className="size-3.5" />
|
|
{audit.checkedDomain}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Status</p>
|
|
<p>
|
|
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
|
|
<p>{audit.checkedPages.length}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
|
|
<div className="text-sm">{leadSummary(lead)}</div>
|
|
</div>
|
|
{audit.internalSummary ? (
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Interne Notiz</p>
|
|
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Geprüfte Befunde</CardTitle>
|
|
<CardDescription>
|
|
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{findings.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Noch keine verifizierten Befunde gespeichert.
|
|
</p>
|
|
) : (
|
|
<ul className="grid gap-3">
|
|
{findings.map((finding) => (
|
|
<li className="rounded-md border p-3 text-sm" key={finding._id}>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="font-medium">{finding.claim}</p>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{finding.customerBenefit}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
|
|
Priorität {finding.severity}
|
|
</Badge>
|
|
<Badge variant="outline">
|
|
{Math.round(finding.confidence * 100)}% sicher
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<p className="mt-3">
|
|
<span className="font-medium">Empfehlung: </span>
|
|
{finding.recommendation}
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{finding.evidenceRefs.map((ref) => (
|
|
<Badge variant="outline" key={ref.id}>
|
|
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Geprüfte Seiten</CardTitle>
|
|
<CardDescription>
|
|
Kompakte Evidence aus Website-Enrichment und Screenshot-Erfassung.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{checkedPageEvidence.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Keine Seiten-Evidence gespeichert</p>
|
|
) : (
|
|
<ul className="grid gap-3">
|
|
{checkedPageEvidence.map((page, index) => (
|
|
<li
|
|
className="rounded-md border p-3 text-sm"
|
|
key={`${page.url}-${index}`}
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="break-words font-medium">
|
|
{page.title ?? page.finalUrl ?? page.sourceUrl ?? page.url}
|
|
</p>
|
|
<p className="mt-1 break-all text-xs text-muted-foreground">
|
|
{page.finalUrl ?? page.sourceUrl ?? page.url}
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline">{getPageKindLabel(page.pageKind)}</Badge>
|
|
</div>
|
|
|
|
{page.visibleTextExcerpt ? (
|
|
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">
|
|
{page.visibleTextExcerpt}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Badge variant={page.missingMetaDescription === true ? "secondary" : "outline"}>
|
|
Meta: {metaSignalText(page)}
|
|
</Badge>
|
|
<Badge variant="outline">
|
|
Kontaktformular:{" "}
|
|
{signalText(page.hasContactFormSignal, "Signal", "Kein Signal")}
|
|
</Badge>
|
|
<Badge variant="outline">
|
|
CTA: {signalText(page.hasContactCtaSignal, "Signal", "Kein Signal")}
|
|
</Badge>
|
|
<Badge variant="outline">
|
|
Interne Links: {page.brokenInternalLinkCount ?? "Unbekannt"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{page.screenshots.length > 0 ? (
|
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
{page.screenshots.map((screenshot) => (
|
|
<figure
|
|
className="overflow-hidden rounded-md border bg-muted/20"
|
|
key={`${screenshot.id}-${screenshot.viewport}`}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={screenshot.url}
|
|
alt={`${screenshot.viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${screenshot.sourceUrl}`}
|
|
width={screenshot.width}
|
|
height={screenshot.height}
|
|
className="aspect-[16/10] w-full object-cover"
|
|
/>
|
|
<figcaption className="flex items-center justify-between gap-2 border-t px-2 py-1 text-xs text-muted-foreground">
|
|
<span className="font-medium">
|
|
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
|
|
</span>
|
|
<span className="truncate">{screenshot.sourceUrl}</span>
|
|
</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Verwendete Skills</CardTitle>
|
|
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{usedSkills.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
|
|
) : (
|
|
<ul className="grid gap-2">
|
|
{usedSkills.map((skill, index) => (
|
|
<li
|
|
className="rounded-md border p-2 text-sm"
|
|
key={`${skill.name}-${index}`}
|
|
>
|
|
<p className="font-medium">{skill.name}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
|
</p>
|
|
{"summary" in skill && skill.summary ? (
|
|
<p className="text-sm text-muted-foreground">{skill.summary}</p>
|
|
) : null}
|
|
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
|
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
|
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
|
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|