feat(analytics): PostHog product-analytics dashboard (/analytics, sase default)

Reusable AnalyticsDashboard over the locally-archived posthog_events stream
(~15min fresh, validated against live PostHog within 1%):
- KPIs with period-over-period deltas: visitors, signups, signup conversion,
  trials, VIN decodes, parts views, OEM copies, WAU
- Activation funnel (signup cohort -> decode -> parts -> OEM)
- Conversion funnel (trial -> checkout -> payment)
- Acquisition by first-touch source (utm_source -> referrer -> direct)
- Daily trend + product quality (decode success %, empty-parts %, empty catalog)

Parameterized by projectKey (reusable across spokes); sase is the default.
Nav entry added. Reads posthog_events locally — no live PostHog API at render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 20:21:29 +03:00
parent cf7a6b910c
commit a9e0d93aa1
4 changed files with 470 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getKpis,
getActivationFunnel,
getConversionFunnel,
getAcquisitionBySource,
getDailyTrend,
getProductQuality,
type Kpi,
type FunnelStep,
} from "@/lib/analytics/posthog-metrics";
// Reusable analytics dashboard for any project's archived PostHog stream.
export async function AnalyticsDashboard({ projectKey, days }: { projectKey: string; days: number }) {
const [kpis, activation, conversion, sources, trend, quality] = await Promise.all([
getKpis(projectKey, days),
getActivationFunnel(projectKey, days),
getConversionFunnel(projectKey, days),
getAcquisitionBySource(projectKey, days),
getDailyTrend(projectKey, days),
getProductQuality(projectKey, days),
]);
const maxVisitors = Math.max(1, ...trend.map((t) => t.visitors));
return (
<div className="space-y-6">
{/* KPI grid */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{kpis.map((k) => (
<KpiCard key={k.key} kpi={k} />
))}
</div>
{/* Funnels */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<FunnelCard
title="Aktivasyon hunisi"
desc="Bu dönem kayıt olanların değer anına yolculuğu"
steps={activation}
/>
<FunnelCard
title="Dönüşüm hunisi"
desc="Deneme → ödeme akışı → ödeme"
steps={conversion}
/>
</div>
{/* Acquisition + Product quality */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Kazanım kaynak bazında</CardTitle>
<CardDescription>İlk dokunuş kaynağı (utm_source referrer direct)</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kaynak</TableHead>
<TableHead className="text-right">Ziyaretçi</TableHead>
<TableHead className="text-right">Kayıt</TableHead>
<TableHead className="text-right">Dönüşüm</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sources.map((s) => (
<TableRow key={s.source}>
<TableCell className="font-medium">{s.source}</TableCell>
<TableCell className="text-right tabular-nums">{s.visitors.toLocaleString("tr-TR")}</TableCell>
<TableCell className="text-right tabular-nums">{s.signups}</TableCell>
<TableCell className="text-right tabular-nums">
<Badge variant={s.convPct >= 5 ? "default" : "secondary"}>{s.convPct}%</Badge>
</TableCell>
</TableRow>
))}
{sources.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">veri yok</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Ürün kalitesi</CardTitle>
<CardDescription>Çekirdek değer sinyalleri</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<QualityRow label="Decode başarı oranı" value={`${quality.decodeSuccessPct}%`} sub={`${quality.decodeSuccess}/${quality.decodeAttempts}`} good={quality.decodeSuccessPct >= 75} />
<QualityRow label="Boş parça paneli" value={`${quality.emptyPartsPct}%`} sub={`${quality.emptyPartsViews}/${quality.partsViews} görüntüleme`} good={quality.emptyPartsPct < 10} bad={quality.emptyPartsPct >= 15} />
<QualityRow label="Boş katalog CTA" value={`${quality.emptyCatalogCtas}`} sub="“parça yok” tıklaması" good={quality.emptyCatalogCtas === 0} />
</CardContent>
</Card>
</div>
{/* Daily trend */}
<Card>
<CardHeader>
<CardTitle className="text-base">Günlük trend</CardTitle>
<CardDescription>Ziyaretçi · kayıt · decode (son {days} gün)</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Gün</TableHead>
<TableHead>Ziyaretçi</TableHead>
<TableHead className="text-right">Kayıt</TableHead>
<TableHead className="text-right">Decode</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trend.slice(-21).map((t) => (
<TableRow key={t.day}>
<TableCell className="font-mono text-xs text-muted-foreground">{t.day}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-2 rounded-sm bg-primary/70" style={{ width: `${Math.max(2, (t.visitors / maxVisitors) * 100)}%` }} />
<span className="tabular-nums text-xs">{t.visitors}</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-xs">{t.signups}</TableCell>
<TableCell className="text-right tabular-nums text-xs">{t.decodes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function KpiCard({ kpi }: { kpi: Kpi }) {
const val = kpi.value.toLocaleString("tr-TR") + (kpi.suffix ?? "");
return (
<Card>
<CardHeader className="pb-1">
<CardDescription className="text-xs">{kpi.label}</CardDescription>
<CardTitle className="flex items-baseline gap-2 text-2xl font-semibold tabular-nums">
{val}
<DeltaBadge delta={kpi.deltaPct} />
</CardTitle>
</CardHeader>
{kpi.hint && <CardContent className="pt-0 text-[11px] text-muted-foreground">{kpi.hint}</CardContent>}
</Card>
);
}
function DeltaBadge({ delta }: { delta: number | null }) {
if (delta === null) return <span className="text-[11px] font-normal text-muted-foreground">yeni</span>;
if (delta === 0) return <span className="text-[11px] font-normal text-muted-foreground">±0%</span>;
const up = delta > 0;
return (
<span className={`text-xs font-normal ${up ? "text-emerald-600" : "text-red-600"}`}>
{up ? "▲" : "▼"} {Math.abs(delta)}%
</span>
);
}
function FunnelCard({ title, desc, steps }: { title: string; desc: string; steps: FunnelStep[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{desc}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{steps.map((s, i) => (
<div key={s.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{s.label}</span>
<span className="tabular-nums text-muted-foreground">
{s.count.toLocaleString("tr-TR")} · {s.pctOfStart}%
{i > 0 && <span className={s.pctOfPrev < 50 ? "ml-2 text-red-600" : "ml-2 text-muted-foreground"}>( {s.pctOfPrev}%)</span>}
</span>
</div>
<div className="h-2 w-full rounded-sm bg-muted">
<div className="h-2 rounded-sm bg-primary" style={{ width: `${Math.max(1, s.pctOfStart)}%` }} />
</div>
</div>
))}
</CardContent>
</Card>
);
}
function QualityRow({ label, value, sub, good, bad }: { label: string; value: string; sub: string; good?: boolean; bad?: boolean }) {
return (
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<Badge variant={bad ? "destructive" : good ? "default" : "secondary"}>{value}</Badge>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { PanelShell } from "@/components/panel-shell";
import { buttonVariants } from "@/components/ui/button";
import { AnalyticsDashboard } from "./_dashboard";
export const dynamic = "force-dynamic";
const RANGES = [7, 30, 90];
// Reusable across spokes; sase is the default. Add a project here once its
// posthog_events stream is archived under that projectKey.
const PROJECTS = [{ key: "sase", label: "Sase.tr" }];
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ project?: string; days?: string }>;
}) {
const sp = await searchParams;
const project = sp.project && PROJECTS.some((p) => p.key === sp.project) ? sp.project! : "sase";
const days = RANGES.includes(Number(sp.days)) ? Number(sp.days) : 30;
const projLabel = PROJECTS.find((p) => p.key === project)?.label ?? project;
return (
<PanelShell title="Analytics">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{PROJECTS.map((p) => (
<a
key={p.key}
href={`/analytics?project=${p.key}&days=${days}`}
className={buttonVariants({ variant: p.key === project ? "default" : "outline", size: "sm" })}
>
{p.label}
</a>
))}
</div>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<a
key={d}
href={`/analytics?project=${project}&days=${d}`}
className={buttonVariants({ variant: d === days ? "default" : "outline", size: "sm" })}
>
{d}g
</a>
))}
</div>
</div>
<p className="text-sm text-muted-foreground">
{projLabel} · PostHog ürün analitiği arşivlenen event akışından (~15dk tazelik). Son {days} gün,
önceki {days} güne göre değişim.
</p>
<AnalyticsDashboard projectKey={project} days={days} />
</PanelShell>
);
}

View File

@@ -3,6 +3,7 @@
import * as React from "react"; import * as React from "react";
import { import {
ActivityIcon, ActivityIcon,
BarChart3Icon,
CommandIcon, CommandIcon,
FolderIcon, FolderIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
@@ -31,6 +32,7 @@ const navMain = [
{ title: "Projects", url: "/projects", icon: <FolderIcon /> }, { title: "Projects", url: "/projects", icon: <FolderIcon /> },
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> }, { title: "Operations", url: "/operations", icon: <TerminalIcon /> },
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> }, { title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
{ title: "Analytics", url: "/analytics", icon: <BarChart3Icon /> },
{ title: "Content", url: "/content", icon: <PenLineIcon /> }, { title: "Content", url: "/content", icon: <PenLineIcon /> },
{ title: "Events", url: "/events", icon: <ActivityIcon /> }, { title: "Events", url: "/events", icon: <ActivityIcon /> },
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> }, { title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },

View File

@@ -0,0 +1,200 @@
import { prisma } from "@/lib/db";
// Professional product analytics over the locally-archived PostHog event stream
// (`posthog_events`, kept current to ~15min by the worker archive job). All
// functions are parameterized by projectKey so the dashboard component is
// reusable across spokes; sase is the default caller.
const n = (v: unknown): number => Number(v ?? 0);
const pct = (num: number, den: number): number => (den > 0 ? Math.round((num / den) * 1000) / 10 : 0);
export type Kpi = { key: string; label: string; value: number; prev: number; deltaPct: number | null; suffix?: string; hint?: string };
export type FunnelStep = { label: string; count: number; pctOfStart: number; pctOfPrev: number };
export type SourceRow = { source: string; visitors: number; signups: number; convPct: number };
export type TrendPoint = { day: string; visitors: number; signups: number; decodes: number };
export type ProductQuality = { partsViews: number; emptyPartsViews: number; emptyPartsPct: number; emptyCatalogCtas: number; decodeSuccess: number; decodeAttempts: number; decodeSuccessPct: number };
function deltaPct(cur: number, prev: number): number | null {
if (prev === 0) return cur > 0 ? null : 0; // null → "new" (no prior baseline)
return Math.round(((cur - prev) / prev) * 1000) / 10;
}
export async function getKpis(projectKey: string, days: number): Promise<Kpi[]> {
const now = Date.now();
const since = new Date(now - days * 864e5);
const prevSince = new Date(now - 2 * days * 864e5);
const wauSince = new Date(now - 7 * 864e5);
const [row] = await prisma.$queryRaw<
Array<Record<string, bigint>>
>`
SELECT
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='$pageview' AND timestamp >= ${since}) AS visitors_cur,
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='$pageview' AND timestamp < ${since}) AS visitors_prev,
count(*) FILTER (WHERE event='user_signed_up' AND timestamp >= ${since}) AS signups_cur,
count(*) FILTER (WHERE event='user_signed_up' AND timestamp < ${since}) AS signups_prev,
count(*) FILTER (WHERE event='trial_started' AND timestamp >= ${since}) AS trials_cur,
count(*) FILTER (WHERE event='trial_started' AND timestamp < ${since}) AS trials_prev,
count(*) FILTER (WHERE event='vin_decode_success' AND timestamp >= ${since}) AS decodes_cur,
count(*) FILTER (WHERE event='vin_decode_success' AND timestamp < ${since}) AS decodes_prev,
count(*) FILTER (WHERE event='parts_panel_viewed' AND timestamp >= ${since}) AS parts_cur,
count(*) FILTER (WHERE event='parts_panel_viewed' AND timestamp < ${since}) AS parts_prev,
count(*) FILTER (WHERE event='oem_code_copied' AND timestamp >= ${since}) AS oem_cur,
count(*) FILTER (WHERE event='oem_code_copied' AND timestamp < ${since}) AS oem_prev,
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE timestamp >= ${wauSince}) AS wau
FROM posthog_events
WHERE "projectKey"=${projectKey} AND timestamp >= ${prevSince}
`;
const r = row ?? {};
const vc = n(r.visitors_cur), vp = n(r.visitors_prev);
const sc = n(r.signups_cur), sp = n(r.signups_prev);
const dc = n(r.decodes_cur), dp = n(r.decodes_prev);
// Activation = share of new signups who reached a successful VIN decode (proxy below via funnel; here a directional rate).
const signupConvCur = pct(sc, vc), signupConvPrev = pct(sp, vp);
return [
{ key: "visitors", label: "Ziyaretçi", value: vc, prev: vp, deltaPct: deltaPct(vc, vp) },
{ key: "signups", label: "Kayıt", value: sc, prev: sp, deltaPct: deltaPct(sc, sp) },
{ key: "signup_conv", label: "Kayıt dönüşümü", value: signupConvCur, prev: signupConvPrev, deltaPct: deltaPct(signupConvCur, signupConvPrev), suffix: "%", hint: "kayıt / ziyaretçi" },
{ key: "trials", label: "Deneme (trial)", value: n(r.trials_cur), prev: n(r.trials_prev), deltaPct: deltaPct(n(r.trials_cur), n(r.trials_prev)) },
{ key: "decodes", label: "VIN decode (başarılı)", value: dc, prev: dp, deltaPct: deltaPct(dc, dp) },
{ key: "parts", label: "Parça görüntüleme", value: n(r.parts_cur), prev: n(r.parts_prev), deltaPct: deltaPct(n(r.parts_cur), n(r.parts_prev)) },
{ key: "oem", label: "OEM kopyalama", value: n(r.oem_cur), prev: n(r.oem_prev), deltaPct: deltaPct(n(r.oem_cur), n(r.oem_prev)), hint: "değer anı (aha moment)" },
{ key: "wau", label: "WAU (7g aktif)", value: n(r.wau), prev: 0, deltaPct: null, hint: "son 7 gün benzersiz aktif" },
];
}
export async function getActivationFunnel(projectKey: string, days: number): Promise<FunnelStep[]> {
const since = new Date(Date.now() - days * 864e5);
const [row] = await prisma.$queryRaw<Array<Record<string, bigint>>>`
WITH cohort AS (
SELECT DISTINCT coalesce("personId","distinctId") AS pid
FROM posthog_events
WHERE "projectKey"=${projectKey} AND event='user_signed_up' AND timestamp >= ${since}
),
acts AS (
SELECT coalesce("personId","distinctId") AS pid, event
FROM posthog_events
WHERE "projectKey"=${projectKey} AND timestamp >= ${since}
AND event IN ('vin_decode_success','parts_panel_viewed','oem_code_copied')
)
SELECT
(SELECT count(*) FROM cohort) AS signed_up,
count(DISTINCT a.pid) FILTER (WHERE a.event='vin_decode_success') AS decoded,
count(DISTINCT a.pid) FILTER (WHERE a.event='parts_panel_viewed') AS parts,
count(DISTINCT a.pid) FILTER (WHERE a.event='oem_code_copied') AS oem
FROM cohort c LEFT JOIN acts a ON a.pid = c.pid
`;
const r = row ?? {};
const start = n(r.signed_up);
const steps = [
{ label: "Kayıt oldu", count: start },
{ label: "VIN decode etti", count: n(r.decoded) },
{ label: "Parça gördü", count: n(r.parts) },
{ label: "OEM kopyaladı", count: n(r.oem) },
];
return steps.map((s, i) => ({
label: s.label,
count: s.count,
pctOfStart: pct(s.count, start),
pctOfPrev: i === 0 ? 100 : pct(s.count, steps[i - 1].count),
}));
}
export async function getConversionFunnel(projectKey: string, days: number): Promise<FunnelStep[]> {
const since = new Date(Date.now() - days * 864e5);
const [row] = await prisma.$queryRaw<Array<Record<string, bigint>>>`
WITH base AS (
SELECT coalesce("personId","distinctId") AS pid, event
FROM posthog_events
WHERE "projectKey"=${projectKey} AND timestamp >= ${since}
AND event IN ('trial_started','checkout_started','payment_success')
)
SELECT
count(DISTINCT pid) FILTER (WHERE event='trial_started') AS trials,
count(DISTINCT pid) FILTER (WHERE event='checkout_started') AS checkouts,
count(DISTINCT pid) FILTER (WHERE event='payment_success') AS payments
FROM base
`;
const r = row ?? {};
const steps = [
{ label: "Deneme başlattı", count: n(r.trials) },
{ label: "Ödeme akışına girdi", count: n(r.checkouts) },
{ label: "Ödeme yaptı", count: n(r.payments) },
];
const start = steps[0].count;
return steps.map((s, i) => ({
label: s.label,
count: s.count,
pctOfStart: pct(s.count, start),
pctOfPrev: i === 0 ? 100 : pct(s.count, steps[i - 1].count),
}));
}
export async function getAcquisitionBySource(projectKey: string, days: number): Promise<SourceRow[]> {
const since = new Date(Date.now() - days * 864e5);
const rows = await prisma.$queryRaw<Array<{ source: string; visitors: bigint; signups: bigint }>>`
WITH person_src AS (
SELECT coalesce("personId","distinctId") AS pid,
coalesce(
nullif((array_agg(properties->>'utm_source' ORDER BY timestamp))[1], ''),
nullif((array_agg(properties->>'$referring_domain' ORDER BY timestamp))[1], ''),
'(direct)'
) AS source
FROM posthog_events
WHERE "projectKey"=${projectKey} AND event='$pageview' AND timestamp >= ${since}
GROUP BY 1
),
signed AS (
SELECT DISTINCT coalesce("personId","distinctId") AS pid
FROM posthog_events
WHERE "projectKey"=${projectKey} AND event='user_signed_up' AND timestamp >= ${since}
)
SELECT ps.source AS source, count(*)::bigint AS visitors, count(s.pid)::bigint AS signups
FROM person_src ps LEFT JOIN signed s ON s.pid = ps.pid
GROUP BY ps.source ORDER BY visitors DESC LIMIT 12
`;
return rows.map((r) => ({ source: r.source, visitors: n(r.visitors), signups: n(r.signups), convPct: pct(n(r.signups), n(r.visitors)) }));
}
export async function getDailyTrend(projectKey: string, days: number): Promise<TrendPoint[]> {
const since = new Date(Date.now() - days * 864e5);
const rows = await prisma.$queryRaw<Array<{ day: string; visitors: bigint; signups: bigint; decodes: bigint }>>`
SELECT to_char(date_trunc('day', timestamp), 'MM-DD') AS day,
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='$pageview') AS visitors,
count(*) FILTER (WHERE event='user_signed_up') AS signups,
count(*) FILTER (WHERE event='vin_decode_success') AS decodes
FROM posthog_events
WHERE "projectKey"=${projectKey} AND timestamp >= ${since}
GROUP BY 1 ORDER BY 1
`;
return rows.map((r) => ({ day: r.day, visitors: n(r.visitors), signups: n(r.signups), decodes: n(r.decodes) }));
}
export async function getProductQuality(projectKey: string, days: number): Promise<ProductQuality> {
const since = new Date(Date.now() - days * 864e5);
const [row] = await prisma.$queryRaw<Array<Record<string, bigint>>>`
SELECT
count(*) FILTER (WHERE event='parts_panel_viewed') AS parts_views,
count(*) FILTER (WHERE event='parts_panel_viewed' AND properties->>'parts_count' = '0') AS empty_parts,
count(*) FILTER (WHERE event='empty_catalog_cta_clicked') AS empty_catalog,
count(*) FILTER (WHERE event='vin_decode_success') AS decode_ok,
count(*) FILTER (WHERE event IN ('vin_decode_success','vin_decode_error')) AS decode_attempts
FROM posthog_events
WHERE "projectKey"=${projectKey} AND timestamp >= ${since}
`;
const r = row ?? {};
const partsViews = n(r.parts_views), emptyParts = n(r.empty_parts);
const decodeOk = n(r.decode_ok), attempts = n(r.decode_attempts);
return {
partsViews,
emptyPartsViews: emptyParts,
emptyPartsPct: pct(emptyParts, partsViews),
emptyCatalogCtas: n(r.empty_catalog),
decodeSuccess: decodeOk,
decodeAttempts: attempts,
decodeSuccessPct: pct(decodeOk, attempts),
};
}