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:
213
apps/web/src/app/analytics/_dashboard.tsx
Normal file
213
apps/web/src/app/analytics/_dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/src/app/analytics/page.tsx
Normal file
55
apps/web/src/app/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
|
|||||||
200
apps/web/src/lib/analytics/posthog-metrics.ts
Normal file
200
apps/web/src/lib/analytics/posthog-metrics.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user