feat(analytics): reported empty categories page

Lists catalog_issue_reported PostHog events (one-click 'Bildir' on
sase.tr empty/error catalog states, sase.tr dev c972a17): time, reporter
email (distinct_id → Sase users), VIN, vehicle, category, source, and
the page it happened on. Category distribution + unique-reporter KPIs,
7/30/90d ranges, linked from /analytics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-11 16:00:36 +03:00
parent e9275e6f44
commit 4454547c59
2 changed files with 264 additions and 0 deletions

View File

@@ -43,6 +43,12 @@ export default async function AnalyticsPage({
{d}g {d}g
</a> </a>
))} ))}
<a
href="/analytics/reported-categories"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Boş kategori bildirimleri
</a>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,258 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { prisma } from "@/lib/db";
import { saseDb } from "@/lib/db-sase";
export const dynamic = "force-dynamic";
// "Çalışmadı mı? Bildir" clicks on Sase.tr's empty/error catalog states land as
// `catalog_issue_reported` PostHog events (sase.tr web — report-catalog-issue.tsx)
// and arrive here through the 15-min event archive. distinct_id is the Sase user
// uuid (posthog identify), so the reporter's email comes from the Sase DB.
type ReportProps = {
vehicle_id?: string;
vehicle_label?: string;
category_id?: string;
category_name?: string;
source?: string;
vin?: string;
page?: string;
$current_url?: string;
};
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export default async function ReportedCategoriesPage({
searchParams,
}: {
searchParams: Promise<{ days?: string }>;
}) {
const sp = await searchParams;
const days = [7, 30, 90].includes(Number(sp.days)) ? Number(sp.days) : 30;
const since = new Date(Date.now() - days * 864e5);
const events = await prisma.posthogEvent.findMany({
where: {
projectKey: "sase",
event: "catalog_issue_reported",
timestamp: { gte: since },
},
orderBy: { timestamp: "desc" },
take: 500,
});
const userIds = Array.from(
new Set(events.map((e) => e.distinctId).filter((id) => UUID_RE.test(id))),
);
const users = userIds.length
? await saseDb.user.findMany({
where: { id: { in: userIds } },
select: { id: true, email: true, name: true },
})
: [];
const userById = new Map(users.map((u) => [u.id, u]));
const rows = events.map((e) => {
const p = (e.properties ?? {}) as ReportProps;
const user = userById.get(e.distinctId);
return {
id: e.uuid,
at: e.timestamp,
email: user?.email ?? null,
name: user?.name ?? null,
distinctId: e.distinctId,
vin: p.vin ?? null,
vehicle: p.vehicle_label ?? p.vehicle_id ?? null,
category: p.category_name ?? p.category_id ?? null,
source: p.source ?? null,
page: p.page ?? (p.$current_url ? new URL(p.$current_url).pathname : null),
};
});
const uniqueReporters = new Set(rows.map((r) => r.email ?? r.distinctId)).size;
const byCategory = new Map<string, number>();
for (const r of rows) {
const key = r.category ?? "(kategori yok)";
byCategory.set(key, (byCategory.get(key) ?? 0) + 1);
}
const topCategories = [...byCategory.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
return (
<PanelShell title="Analytics · Bildirilen boş kategoriler">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href="/analytics" className="hover:underline">
Analytics
</Link>
</div>
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-semibold">Bildirilen boş kategoriler</h2>
<p className="text-sm text-muted-foreground">
Sase.tr&apos;de &quot;Çalışmadı mı? Bildir&quot; tıklamaları son {days} gün,{" "}
{rows.length} bildirim. Event arşivi ~15dk gecikmeli.
</p>
</div>
<div className="flex gap-1">
{[7, 30, 90].map((d) => (
<Link
key={d}
href={`?days=${d}`}
className={buttonVariants({
variant: d === days ? "default" : "outline",
size: "sm",
})}
>
{d}g
</Link>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<Kpi label="Bildirim" value={String(rows.length)} />
<Kpi label="Benzersiz kullanıcı" value={String(uniqueReporters)} />
<Kpi
label="En çok bildirilen"
value={topCategories[0]?.[0] ?? "—"}
small
/>
</div>
{topCategories.length > 0 && (
<Card>
<CardHeader>
<CardDescription>Kategoriye göre dağılım</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{topCategories.map(([cat, n]) => (
<Badge key={cat} variant="outline">
{cat} · {n}
</Badge>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardDescription>Bildirimler</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
Bu pencerede bildirim yok. (Buton tek tıkla rapor gönderiyor; form yok
event&apos;ler 15 dakikalık arşiv turuyla düşer.)
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zaman (TRT)</TableHead>
<TableHead>Kullanıcı</TableHead>
<TableHead>VIN</TableHead>
<TableHead>Araç</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Source</TableHead>
<TableHead>Sayfa</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap font-mono text-xs">
{r.at.toLocaleString("tr-TR", {
timeZone: "Europe/Istanbul",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell className="text-xs">
{r.email ? (
<>
<span>{r.email}</span>
{r.name && (
<span className="ml-1 text-muted-foreground">({r.name})</span>
)}
</>
) : (
<span
className="font-mono text-muted-foreground"
title={r.distinctId}
>
anonim · {r.distinctId.slice(0, 8)}
</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{r.vin ?? "—"}</TableCell>
<TableCell className="text-xs">{r.vehicle ?? "—"}</TableCell>
<TableCell className="text-xs">{r.category ?? "—"}</TableCell>
<TableCell>
{r.source ? (
<Badge variant="outline" className="font-mono text-xs">
{r.source}
</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="max-w-[260px] truncate text-xs">
{r.page ? (
<a
href={`https://sase.tr${r.page}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
title={r.page}
>
{r.page}
</a>
) : (
"—"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}
function Kpi({ label, value, small }: { label: string; value: string; small?: boolean }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle
className={`${small ? "truncate text-base" : "text-2xl"} font-semibold tabular-nums`}
>
{value}
</CardTitle>
</CardHeader>
</Card>
);
}