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:
@@ -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">
|
||||||
|
|||||||
258
apps/web/src/app/analytics/reported-categories/page.tsx
Normal file
258
apps/web/src/app/analytics/reported-categories/page.tsx
Normal 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'de "Çalışmadı mı? Bildir" 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user