Add audit analytics and campaign metrics
This commit is contained in:
29
app/api/internal/rybbit/audit/route.ts
Normal file
29
app/api/internal/rybbit/audit/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
||||
import { AnalyticsDashboard } from "@/components/analytics/analytics-dashboard";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
|
||||
title="Analytics"
|
||||
/>
|
||||
);
|
||||
return <AnalyticsDashboard />;
|
||||
}
|
||||
|
||||
139
components/analytics/analytics-dashboard.tsx
Normal file
139
components/analytics/analytics-dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { RybbitTracking } from "./rybbit-tracking";
|
||||
import { PublicAuditScreenshot } from "./public-audit-screenshot";
|
||||
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
|
||||
|
||||
type PublicAuditPageProps = {
|
||||
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
|
||||
@@ -10,6 +12,7 @@ type PublicAuditPageProps = {
|
||||
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
||||
return (
|
||||
<main className="min-h-dvh bg-slate-50 text-slate-950">
|
||||
<RybbitTracking domain={audit.domain} />
|
||||
<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="max-w-3xl">
|
||||
@@ -105,17 +108,11 @@ export function PublicAuditPage({ audit }: PublicAuditPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
{audit.finalOffer.ctaHref ? (
|
||||
<a
|
||||
<TrackedPublicAuditLink
|
||||
domain={audit.domain}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
27
components/public-audit/rybbit-tracking.tsx
Normal file
27
components/public-audit/rybbit-tracking.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
components/public-audit/tracked-public-audit-link.tsx
Normal file
51
components/public-audit/tracked-public-audit-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
@@ -13,7 +13,9 @@ import type * as auditGenerationAction from "../auditGenerationAction.js";
|
||||
import type * as auditInputs from "../auditInputs.js";
|
||||
import type * as audits from "../audits.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 crons from "../crons.js";
|
||||
import type * as domain from "../domain.js";
|
||||
import type * as http from "../http.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 pageSpeedAction from "../pageSpeedAction.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 storage from "../storage.js";
|
||||
import type * as websiteEnrichment from "../websiteEnrichment.js";
|
||||
@@ -40,7 +43,9 @@ declare const fullApi: ApiFromModules<{
|
||||
auditInputs: typeof auditInputs;
|
||||
audits: typeof audits;
|
||||
blacklist: typeof blacklist;
|
||||
campaignMetrics: typeof campaignMetrics;
|
||||
campaigns: typeof campaigns;
|
||||
crons: typeof crons;
|
||||
domain: typeof domain;
|
||||
http: typeof http;
|
||||
leadDiscovery: typeof leadDiscovery;
|
||||
@@ -50,6 +55,7 @@ declare const fullApi: ApiFromModules<{
|
||||
pageSpeed: typeof pageSpeed;
|
||||
pageSpeedAction: typeof pageSpeedAction;
|
||||
runs: typeof runs;
|
||||
scheduledJobs: typeof scheduledJobs;
|
||||
settings: typeof settings;
|
||||
storage: typeof storage;
|
||||
websiteEnrichment: typeof websiteEnrichment;
|
||||
|
||||
134
convex/campaignMetrics.ts
Normal file
134
convex/campaignMetrics.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -5,13 +5,13 @@ import { internal } from "./_generated/api";
|
||||
const crons = cronJobs();
|
||||
|
||||
crons.interval(
|
||||
"Kampagnen nach Cadence starten",
|
||||
"campaign cadence runner",
|
||||
{ hours: 1 },
|
||||
internal.scheduledJobs.runDueCampaigns,
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"Audit-Lifecycle prüfen",
|
||||
"audit lifecycle runner",
|
||||
{ hours: 24 },
|
||||
internal.scheduledJobs.runAuditLifecycle,
|
||||
);
|
||||
|
||||
195
lib/rybbit-analytics.ts
Normal file
195
lib/rybbit-analytics.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
72
tests/analytics-source.test.ts
Normal file
72
tests/analytics-source.test.ts
Normal 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/);
|
||||
});
|
||||
72
tests/rybbit-analytics.test.ts
Normal file
72
tests/rybbit-analytics.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user