Add audit analytics and campaign metrics

This commit is contained in:
2026-06-05 21:43:43 +02:00
parent 70951789d2
commit df8ca1f049
12 changed files with 737 additions and 20 deletions

View File

@@ -0,0 +1,29 @@
import { fetchRybbitAuditAnalytics } from "@/lib/rybbit-analytics";
export async function GET(request: Request) {
const url = new URL(request.url);
const auditPath = url.searchParams.get("path") ?? "";
if (!auditPath.startsWith("/audit/")) {
return Response.json({
ok: false,
error: "Audit-Pfad fehlt.",
data: null,
}, { status: 400 });
}
const result = await fetchRybbitAuditAnalytics({
apiUrl: process.env.RYBBIT_API_URL,
apiKey: process.env.RYBBIT_API_KEY,
siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID,
auditPath,
startDate: url.searchParams.get("startDate") ?? undefined,
endDate: url.searchParams.get("endDate") ?? undefined,
});
if (!result.ok) {
return Response.json({ ok: false, error: result.error, data: result.data });
}
return Response.json({ ok: true, data: result.data });
}

View File

@@ -1,10 +1,5 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { AnalyticsDashboard } from "@/components/analytics/analytics-dashboard";
export default function AnalyticsPage() { export default function AnalyticsPage() {
return ( return <AnalyticsDashboard />;
<DashboardPlaceholderPage
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
title="Analytics"
/>
);
} }

View File

@@ -0,0 +1,139 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import { Activity, BarChart3, Filter, MousePointerClick } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
const metricLabels: Record<string, string> = {
foundLeads: "Gefundene Leads",
leadsWithContact: "Mit Kontakt",
missingContact: "Kontakt fehlt",
auditsCreated: "Audits erstellt",
approvalsOpen: "Freigaben offen",
emailsSent: "E-Mails gesendet",
followUpsPlanned: "Follow-ups geplant",
followUpsSent: "Follow-ups gesendet",
responses: "Antworten",
conversations: "Gespräche",
offers: "Angebote",
wins: "Gewonnen",
losses: "Verloren",
skippedDuplicates: "Duplikate übersprungen",
skippedBlacklisted: "Sperrliste übersprungen",
rybbitAuditOpens: "Audit-Öffnungen",
rybbitCtaClicks: "CTA-Klicks",
};
export function AnalyticsDashboard() {
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 });
const metricEntries = useMemo(() => {
if (!dashboard) {
return [];
}
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
}, [dashboard]);
if (dashboard === undefined) {
return (
<section className="space-y-4">
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</section>
);
}
return (
<section className="space-y-4">
<header className="border-b pb-3">
<p className="text-sm text-muted-foreground">Kampagnen-Reporting</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">Analytics</h1>
</header>
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Filter className="size-5" />
Filter
</CardTitle>
<CardDescription>
Kampagne, Nische, PLZ, Radius, Priorität, Status und Zeitraum.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-4">
<p>Kampagne: {dashboard.filters.campaigns.length}</p>
<p>Nische: {dashboard.filters.niches.length}</p>
<p>PLZ: {dashboard.filters.postalCodes.length}</p>
<p>Radius: Kampagnenradius</p>
<p>Priorität: Hoch/Mittel/Niedrig</p>
<p>Status: Funnel-Status</p>
<p>Zeitraum: Erstellungsdatum</p>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricEntries.map(([key, value]) => (
<Card key={key}>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{metricLabels[key]}</p>
<p className="mt-2 text-2xl font-semibold">{value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.7fr)]">
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Activity className="size-5" />
Run-Details
</CardTitle>
<CardDescription>
Neue Leads, übersprungene Duplikate, Sperrliste, Fehler und erzeugte Audits.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
{dashboard.runs.length === 0 ? (
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
) : (
dashboard.runs.map((run) => (
<div className="rounded-md border p-3" key={run.id}>
<div className="flex flex-wrap justify-between gap-2">
<p className="font-medium">{run.status}</p>
<p className="text-muted-foreground">
Leads {run.newLeads} · Audits {run.auditsGenerated} · Fehler {run.errors}
</p>
</div>
{run.errorSummary ? (
<p className="mt-1 text-xs text-destructive">{run.errorSummary}</p>
) : null}
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<MousePointerClick className="size-5" />
Rybbit
</CardTitle>
<CardDescription>
Audit-Öffnungen und CTA-Aktivität werden bei Bedarf aus der Rybbit API geladen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.</p>
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
</CardContent>
</Card>
</div>
</section>
);
}

View File

@@ -1,7 +1,9 @@
import { ArrowRight, CheckCircle2, ExternalLink } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import type { PublicAuditRenderState } from "@/lib/audits/public-audit-types"; import type { PublicAuditRenderState } from "@/lib/audits/public-audit-types";
import { RybbitTracking } from "./rybbit-tracking";
import { PublicAuditScreenshot } from "./public-audit-screenshot"; import { PublicAuditScreenshot } from "./public-audit-screenshot";
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
type PublicAuditPageProps = { type PublicAuditPageProps = {
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"]; audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
@@ -10,6 +12,7 @@ type PublicAuditPageProps = {
export function PublicAuditPage({ audit }: PublicAuditPageProps) { export function PublicAuditPage({ audit }: PublicAuditPageProps) {
return ( return (
<main className="min-h-dvh bg-slate-50 text-slate-950"> <main className="min-h-dvh bg-slate-50 text-slate-950">
<RybbitTracking domain={audit.domain} />
<section className="border-b border-slate-200 bg-white"> <section className="border-b border-slate-200 bg-white">
<div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8"> <div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8">
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -105,17 +108,11 @@ export function PublicAuditPage({ audit }: PublicAuditPageProps) {
</p> </p>
</div> </div>
{audit.finalOffer.ctaHref ? ( {audit.finalOffer.ctaHref ? (
<a <TrackedPublicAuditLink
domain={audit.domain}
href={audit.finalOffer.ctaHref} href={audit.finalOffer.ctaHref}
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0" label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
> />
{audit.finalOffer.ctaLabel ?? "Audit besprechen"}
{audit.finalOffer.ctaHref.startsWith("/") ? (
<ArrowRight className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
)}
</a>
) : null} ) : null}
</div> </div>
</section> </section>

View File

@@ -0,0 +1,27 @@
import Script from "next/script";
type RybbitTrackingProps = {
domain: string;
};
export function RybbitTracking({ domain }: RybbitTrackingProps) {
const siteId = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID?.trim();
if (!siteId) {
return null;
}
const apiUrl = process.env.RYBBIT_API_URL?.trim() || "https://app.rybbit.io";
const src = `${apiUrl.replace(/\/$/, "")}/api/script.js`;
return (
<Script
async
data-site-id={siteId}
data-domain={domain}
defer
id="rybbit-public-audit"
src={src}
strategy="afterInteractive"
/>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { ArrowRight, ExternalLink } from "lucide-react";
declare global {
interface Window {
rybbit?: {
event?: (name: string, properties?: Record<string, string | number>) => void;
};
}
}
type TrackedPublicAuditLinkProps = {
href: string;
label: string;
domain: string;
};
export function TrackedPublicAuditLink({
href,
label,
domain,
}: TrackedPublicAuditLinkProps) {
const isInternal = href.startsWith("/");
return (
<a
href={href}
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0"
onClick={() => {
window.rybbit?.event?.("audit_cta_click", {
domain,
target: isInternal ? "cta" : "outbound_cta",
});
if (!isInternal) {
window.rybbit?.event?.("audit_website_link_click", {
domain,
href,
});
}
}}
>
{label}
{isInternal ? (
<ArrowRight className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
)}
</a>
);
}

View File

@@ -13,7 +13,9 @@ import type * as auditGenerationAction from "../auditGenerationAction.js";
import type * as auditInputs from "../auditInputs.js"; import type * as auditInputs from "../auditInputs.js";
import type * as audits from "../audits.js"; import type * as audits from "../audits.js";
import type * as blacklist from "../blacklist.js"; import type * as blacklist from "../blacklist.js";
import type * as campaignMetrics from "../campaignMetrics.js";
import type * as campaigns from "../campaigns.js"; import type * as campaigns from "../campaigns.js";
import type * as crons from "../crons.js";
import type * as domain from "../domain.js"; import type * as domain from "../domain.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as leadDiscovery from "../leadDiscovery.js"; import type * as leadDiscovery from "../leadDiscovery.js";
@@ -23,6 +25,7 @@ import type * as outreachSendAction from "../outreachSendAction.js";
import type * as pageSpeed from "../pageSpeed.js"; import type * as pageSpeed from "../pageSpeed.js";
import type * as pageSpeedAction from "../pageSpeedAction.js"; import type * as pageSpeedAction from "../pageSpeedAction.js";
import type * as runs from "../runs.js"; import type * as runs from "../runs.js";
import type * as scheduledJobs from "../scheduledJobs.js";
import type * as settings from "../settings.js"; import type * as settings from "../settings.js";
import type * as storage from "../storage.js"; import type * as storage from "../storage.js";
import type * as websiteEnrichment from "../websiteEnrichment.js"; import type * as websiteEnrichment from "../websiteEnrichment.js";
@@ -40,7 +43,9 @@ declare const fullApi: ApiFromModules<{
auditInputs: typeof auditInputs; auditInputs: typeof auditInputs;
audits: typeof audits; audits: typeof audits;
blacklist: typeof blacklist; blacklist: typeof blacklist;
campaignMetrics: typeof campaignMetrics;
campaigns: typeof campaigns; campaigns: typeof campaigns;
crons: typeof crons;
domain: typeof domain; domain: typeof domain;
http: typeof http; http: typeof http;
leadDiscovery: typeof leadDiscovery; leadDiscovery: typeof leadDiscovery;
@@ -50,6 +55,7 @@ declare const fullApi: ApiFromModules<{
pageSpeed: typeof pageSpeed; pageSpeed: typeof pageSpeed;
pageSpeedAction: typeof pageSpeedAction; pageSpeedAction: typeof pageSpeedAction;
runs: typeof runs; runs: typeof runs;
scheduledJobs: typeof scheduledJobs;
settings: typeof settings; settings: typeof settings;
storage: typeof storage; storage: typeof storage;
websiteEnrichment: typeof websiteEnrichment; websiteEnrichment: typeof websiteEnrichment;

134
convex/campaignMetrics.ts Normal file
View File

@@ -0,0 +1,134 @@
import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { query } from "./_generated/server";
const priority = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadStatus = v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
);
export const getDashboard = query({
args: {
campaignId: v.optional(v.id("campaigns")),
niche: v.optional(v.string()),
postalCode: v.optional(v.string()),
radiusKm: v.optional(v.number()),
priority: v.optional(priority),
status: v.optional(leadStatus),
from: v.optional(v.number()),
to: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
const campaigns = await ctx.db.query("campaigns").order("desc").take(100);
const leads = await ctx.db.query("leads").order("desc").take(500);
const audits = await ctx.db.query("audits").order("desc").take(500);
const outreach = await ctx.db.query("outreachRecords").order("desc").take(500);
const runs = await ctx.db
.query("agentRuns")
.withIndex("by_type", (q) => q.eq("type", "campaign"))
.order("desc")
.take(100);
const filteredLeads = leads.filter((lead) => {
const campaign = lead.campaignId
? campaigns.find((row) => row._id === lead.campaignId)
: null;
if (args.campaignId && lead.campaignId !== args.campaignId) {
return false;
}
if (args.niche && lead.niche !== args.niche) {
return false;
}
if (args.postalCode && lead.postalCode !== args.postalCode) {
return false;
}
if (args.radiusKm && campaign?.radiusKm !== args.radiusKm) {
return false;
}
if (args.priority && lead.priority !== args.priority) {
return false;
}
if (args.status && lead.contactStatus !== args.status) {
return false;
}
if (args.from && lead.createdAt < args.from) {
return false;
}
if (args.to && lead.createdAt > args.to) {
return false;
}
return true;
});
const leadIds = new Set(filteredLeads.map((lead) => lead._id));
const filteredAudits = audits.filter((audit) => leadIds.has(audit.leadId));
const filteredOutreach = outreach.filter((row) => leadIds.has(row.leadId));
const runRows = runs.slice(0, limit).map((run) => ({
id: run._id,
campaignId: run.campaignId ?? null,
status: run.status,
newLeads: run.counters?.leadsCreated ?? 0,
skippedDuplicates: 0,
skippedBlacklisted: 0,
errors: run.counters?.errors ?? 0,
auditsGenerated: run.counters?.auditsCreated ?? 0,
updatedAt: run.updatedAt,
errorSummary: run.errorSummary ?? null,
}));
return {
filters: {
campaigns: campaigns.map((campaign) => ({
id: campaign._id,
name: campaign.name,
})),
niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(),
postalCodes: [...new Set(leads.map((lead) => lead.postalCode).filter(Boolean))].sort(),
},
metrics: {
foundLeads: filteredLeads.length,
leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length,
missingContact: filteredLeads.filter((lead) => lead.contactStatus === "missing_contact").length,
auditsCreated: filteredAudits.length,
approvalsOpen: filteredOutreach.filter((row) => row.approvalStatus === "draft").length,
emailsSent: filteredOutreach.filter((row) => row.sendStatus === "sent").length,
followUpsPlanned: filteredOutreach.filter((row) => row.salesStatus === "follow_up_planned").length,
followUpsSent: filteredOutreach.filter((row) => row.salesStatus === "follow_up_sent").length,
responses: filteredOutreach.filter((row) => row.salesStatus === "reply_received").length,
conversations: filteredOutreach.filter((row) =>
row.salesStatus === "meeting_scheduled" ||
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent" ||
row.salesStatus === "won",
).length,
offers: filteredOutreach.filter((row) =>
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent",
).length,
wins: filteredOutreach.filter((row) => row.salesStatus === "won").length,
losses: filteredOutreach.filter((row) => row.salesStatus === "lost").length,
skippedDuplicates: runRows.reduce((total, run) => total + run.skippedDuplicates, 0),
skippedBlacklisted: runRows.reduce((total, run) => total + run.skippedBlacklisted, 0),
rybbitAuditOpens: 0,
rybbitCtaClicks: 0,
},
runs: runRows,
};
},
});

View File

@@ -5,13 +5,13 @@ import { internal } from "./_generated/api";
const crons = cronJobs(); const crons = cronJobs();
crons.interval( crons.interval(
"Kampagnen nach Cadence starten", "campaign cadence runner",
{ hours: 1 }, { hours: 1 },
internal.scheduledJobs.runDueCampaigns, internal.scheduledJobs.runDueCampaigns,
); );
crons.interval( crons.interval(
"Audit-Lifecycle prüfen", "audit lifecycle runner",
{ hours: 24 }, { hours: 24 },
internal.scheduledJobs.runAuditLifecycle, internal.scheduledJobs.runAuditLifecycle,
); );

195
lib/rybbit-analytics.ts Normal file
View File

@@ -0,0 +1,195 @@
export type RybbitEvent = {
type?: string;
timestamp?: string;
pathname?: string;
path?: string;
url?: string;
event_name?: string;
name?: string;
properties?: Record<string, unknown>;
};
export type AuditRybbitSummary = {
opened: boolean;
viewCount: number;
lastView: string | null;
ctaClicks: number;
websiteLinkClicks: number;
deviceTypes: string[];
};
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<Pick<Response, "ok" | "status" | "json" | "text">>;
export function buildRybbitEventsUrl(input: {
apiUrl: string;
siteId: string;
startDate?: string;
endDate?: string;
}) {
const base = input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`;
const url = new URL(`api/sites/${encodeURIComponent(input.siteId)}/events`, base);
if (input.startDate) {
url.searchParams.set("start_date", input.startDate);
}
if (input.endDate) {
url.searchParams.set("end_date", input.endDate);
}
return url;
}
function eventPath(event: RybbitEvent) {
const propertyPath =
typeof event.properties?.pathname === "string"
? event.properties.pathname
: typeof event.properties?.path === "string"
? event.properties.path
: undefined;
return event.pathname ?? event.path ?? propertyPath ?? event.url ?? "";
}
function eventName(event: RybbitEvent) {
const propertyName =
typeof event.properties?.event_name === "string"
? event.properties.event_name
: typeof event.properties?.name === "string"
? event.properties.name
: undefined;
return event.event_name ?? event.name ?? propertyName ?? "";
}
function eventDevice(event: RybbitEvent) {
const value =
event.properties?.deviceType ??
event.properties?.device_type ??
event.properties?.device;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function isAuditEvent(event: RybbitEvent, auditPath: string) {
const path = eventPath(event);
return path === auditPath || path.endsWith(auditPath);
}
export function summarizeAuditRybbitEvents(
events: RybbitEvent[],
auditPath: string,
): AuditRybbitSummary {
const matchingEvents = events.filter((event) => isAuditEvent(event, auditPath));
const pageviews = matchingEvents.filter((event) => event.type === "pageview");
const ctaClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "custom_event" && name === "audit_cta_click";
});
const websiteLinkClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "outbound_link" || name === "audit_website_link_click";
});
const lastView = pageviews
.map((event) => event.timestamp)
.filter((timestamp): timestamp is string => Boolean(timestamp))
.sort()
.at(-1) ?? null;
const deviceTypes = [
...new Set(
matchingEvents
.map(eventDevice)
.filter((device): device is string => device !== null),
),
].sort();
return {
opened: pageviews.length > 0,
viewCount: pageviews.length,
lastView,
ctaClicks: ctaClicks.length,
websiteLinkClicks: websiteLinkClicks.length,
deviceTypes,
};
}
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
if (Array.isArray(payload)) {
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
}
if (
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
Array.isArray((payload as { data?: unknown }).data)
) {
return (payload as { data: unknown[] }).data.filter(
(event): event is RybbitEvent => typeof event === "object" && event !== null,
);
}
return [];
}
export async function fetchRybbitAuditAnalytics(input: {
apiUrl?: string;
apiKey?: string;
siteId?: string;
auditPath: string;
startDate?: string;
endDate?: string;
fetchImpl?: FetchLike;
}) {
if (!input.apiUrl || !input.apiKey || !input.siteId) {
return {
ok: false as const,
error: "Rybbit ist nicht vollständig konfiguriert.",
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
try {
const response = await (input.fetchImpl ?? fetch)(
buildRybbitEventsUrl({
apiUrl: input.apiUrl,
siteId: input.siteId,
startDate: input.startDate,
endDate: input.endDate,
}),
{
headers: {
Authorization: `Bearer ${input.apiKey}`,
},
},
);
if (!response.ok) {
const body = await response.text();
return {
ok: false as const,
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
const payload = await response.json();
return {
ok: true as const,
data: summarizeAuditRybbitEvents(
normalizeEventsPayload(payload),
input.auditPath,
),
};
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : String(error),
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
}

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
function source(path: string) {
return readFileSync(join(process.cwd(), ...path.split("/")), "utf8");
}
test("Rybbit tracking is mounted only in public audit presentation", () => {
const publicAuditSource = source("components/public-audit/public-audit-page.tsx");
const dashboardLayoutSource = source("app/dashboard/layout.tsx");
const dashboardAnalyticsSource = source("app/dashboard/analytics/page.tsx");
assert.match(publicAuditSource, /RybbitTracking/);
assert.match(publicAuditSource, /TrackedPublicAuditLink/);
assert.doesNotMatch(dashboardLayoutSource, /RybbitTracking|rybbit/i);
assert.doesNotMatch(dashboardAnalyticsSource, /next\/script|RybbitTracking/);
});
test("internal Rybbit route fetches audit analytics on demand with graceful errors", () => {
const routePath = "app/api/internal/rybbit/audit/route.ts";
assert.equal(existsSync(join(process.cwd(), ...routePath.split("/"))), true);
const routeSource = source(routePath);
assert.match(routeSource, /export async function GET/);
assert.match(routeSource, /fetchRybbitAuditAnalytics/);
assert.match(routeSource, /RYBBIT_API_KEY/);
assert.match(routeSource, /return Response\.json\(\{ ok: false/);
});
test("campaign metrics query exposes lightweight funnel and run metrics", () => {
const metricsSource = source("convex/campaignMetrics.ts");
assert.match(metricsSource, /export const getDashboard = query/);
for (const label of [
"foundLeads",
"leadsWithContact",
"missingContact",
"auditsCreated",
"approvalsOpen",
"emailsSent",
"followUpsPlanned",
"followUpsSent",
"responses",
"conversations",
"offers",
"wins",
"losses",
"skippedDuplicates",
"skippedBlacklisted",
]) {
assert.match(metricsSource, new RegExp(label));
}
});
test("analytics dashboard renders filters, Convex metrics, and Rybbit error states", () => {
const pageSource = source("app/dashboard/analytics/page.tsx");
const componentSource = source("components/analytics/analytics-dashboard.tsx");
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /AnalyticsDashboard/);
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
assert.match(componentSource, /Kampagne/);
assert.match(componentSource, /Nische/);
assert.match(componentSource, /PLZ/);
assert.match(componentSource, /Radius/);
assert.match(componentSource, /Priorität/);
assert.match(componentSource, /Status/);
assert.match(componentSource, /Zeitraum/);
assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/);
});

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildRybbitEventsUrl,
summarizeAuditRybbitEvents,
type RybbitEvent,
} from "../lib/rybbit-analytics";
test("buildRybbitEventsUrl targets the documented events endpoint", () => {
const url = buildRybbitEventsUrl({
apiUrl: "https://analytics.example.com/",
siteId: "site_123",
startDate: "2026-06-01T00:00:00.000Z",
endDate: "2026-06-05T00:00:00.000Z",
});
assert.equal(
url.toString(),
"https://analytics.example.com/api/sites/site_123/events?start_date=2026-06-01T00%3A00%3A00.000Z&end_date=2026-06-05T00%3A00%3A00.000Z",
);
});
test("summarizeAuditRybbitEvents extracts opens, clicks, last view, and devices", () => {
const events: RybbitEvent[] = [
{
type: "pageview",
timestamp: "2026-06-05T10:00:00.000Z",
pathname: "/audit/demo",
properties: { device: "desktop" },
},
{
type: "custom_event",
timestamp: "2026-06-05T10:05:00.000Z",
event_name: "audit_cta_click",
pathname: "/audit/demo",
properties: { target: "cta", deviceType: "mobile" },
},
{
type: "outbound_link",
timestamp: "2026-06-05T10:06:00.000Z",
pathname: "/audit/demo",
properties: { href: "https://example.com", device: "mobile" },
},
{
type: "pageview",
timestamp: "2026-06-05T11:00:00.000Z",
pathname: "/pricing",
properties: { device: "desktop" },
},
];
assert.deepEqual(summarizeAuditRybbitEvents(events, "/audit/demo"), {
opened: true,
viewCount: 1,
lastView: "2026-06-05T10:00:00.000Z",
ctaClicks: 1,
websiteLinkClicks: 1,
deviceTypes: ["desktop", "mobile"],
});
});
test("summarizeAuditRybbitEvents returns graceful empty metrics", () => {
assert.deepEqual(summarizeAuditRybbitEvents([], "/audit/demo"), {
opened: false,
viewCount: 0,
lastView: null,
ctaClicks: 0,
websiteLinkClicks: 0,
deviceTypes: [],
});
});