feat(sase): VIN business-impact dashboard — Faz 3 (no-quota model)

Sase.tr has no monthly VIN quota — access is gated by plan.brandCount
+ user_brands assignments. The PRD's Faz 3 quota-centric metrics don't
apply; this commit ships the 8 business signals that DO apply to that
model. All queries are RO joins of query_logs to subscription/plan/brand.

1. Tier × decode volume (30d) — unique users, decode count, decodes/user,
   and success rate per plan. Plan derived from each user's most-recent
   active/trial subscription.
2. Brand-tier alignment — for plans with brandCount > 0, the fraction
   of decodes that landed on a brand the user actually has access to via
   user_brands. Low alignment = wrong tier or upsell signal.
3. Dormant payers — active subscription + no decode in the last 14 days.
   Sorted by lastDecodeAt ASC (most dormant first), 30 rows.
4. Empty-handed payers — active subscription + zero lifetime decodes.
   Onboarding-broken signal, sorted oldest start_date first.
5. Trial → paid funnel — bucket trial users by decodes-during-trial
   (0, 1–2, 3–5, 6–10, 11–25, 26+) and show the conversion rate (any
   subsequent active/cancelled subscription) per bucket. Answers
   "how many decodes is the aha moment".
6. Retention cohort — weekly signup cohorts (mature ≥30d), split by
   "had a successful decode in first 7 days" vs not; columns show 30d
   active rate per bucket and the retention lift (pp difference).
7. Power users — top 20 by 30d decode count: plan, decode count,
   brand diversity, top brand + occurrences, success rate.
8. Tier mismatch — brand-spesifik plan kullanıcıları whose 30d decodes
   include ≥3 hits on brands they don't have access to. Upsell candidates
   (or refund/downgrade conversation).

All tables link emails to /projects/sase/users/[id] (existing detail page),
so the dashboard is a "find users to talk to" tool, not just numbers.

VIN dashboard header now has a "Business →" pill next to the time range
picker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 12:49:14 +03:00
parent 717e4ae369
commit e583f6b6be
3 changed files with 1169 additions and 14 deletions

View File

@@ -0,0 +1,517 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getTierUsage,
getBrandTierAlignment,
getDormantPayers,
getEmptyHandedPayers,
getTrialFunnel,
getRetentionCohorts,
getPowerUsers,
getTierMismatchUsers,
} from "@/lib/sase/business-impact";
export const dynamic = "force-dynamic";
export default async function BusinessImpactPage() {
const [tiers, alignment, dormant, empty, funnel, cohorts, powerUsers, mismatch] =
await Promise.all([
getTierUsage(30),
getBrandTierAlignment(30),
getDormantPayers(14, 30),
getEmptyHandedPayers(30),
getTrialFunnel(90),
getRetentionCohorts(8),
getPowerUsers(20, 30),
getTierMismatchUsers(30, 3, 20),
]);
return (
<PanelShell title="Sase · VIN Business Impact">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href="/projects/sase/vin-decode" className="hover:underline">
VIN Decode dashboard
</Link>
</div>
<div>
<h2 className="text-xl font-semibold">Business Impact</h2>
<p className="text-sm text-muted-foreground">
Decode davranışı ile subscription/plan/brand verisinin kesişimi.
</p>
</div>
{/* ── 1. Tier × decode ───────────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>1. Tier × decode hacmi (son 30g)</CardDescription>
</CardHeader>
<CardContent>
{tiers.length === 0 ? (
<p className="text-sm text-muted-foreground">Veri yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Brand sayısı</TableHead>
<TableHead className="text-right">Kullanıcı</TableHead>
<TableHead className="text-right">Decode</TableHead>
<TableHead className="text-right">User başına</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tiers.map((t) => (
<TableRow key={t.planId ?? "none"}>
<TableCell>
<Badge variant="outline">{t.planName ?? "—"}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{t.planBrandCount === 0
? "tüm"
: t.planBrandCount ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{t.uniqueUsers.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums">
{t.totalDecodes.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums">
{t.decodesPerUser.toFixed(1)}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(t.successRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ── 2. Brand-tier alignment ─────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
2. Brand-tier uyumu (brand-spesifik plan kullanıcıları, son 30g)
</CardDescription>
</CardHeader>
<CardContent>
{alignment.length === 0 ? (
<p className="text-sm text-muted-foreground">Brand-spesifik plan kullanımı yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Brand sayısı</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Atanmış brand</TableHead>
<TableHead className="text-right">Off-brand</TableHead>
<TableHead className="text-right">Uyum oranı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alignment.map((a) => (
<TableRow key={a.planName}>
<TableCell>
<Badge variant="outline">{a.planName}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{a.planBrandCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{a.totalDecodes.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-emerald-600">
{a.onAssignedBrand.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{a.offAssignedBrand.toLocaleString("tr-TR")}
</TableCell>
<TableCell
className={`text-right tabular-nums ${
a.alignmentRate >= 0.9
? "text-emerald-600"
: a.alignmentRate >= 0.75
? ""
: "text-destructive"
}`}
>
{pct(a.alignmentRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
Düşük uyum = ya yanlış tier'da kullanıcı, ya da farklı brand'leri görmek isteyen
upsell adayı. Tier-mismatch tablosunda kişi başına ayrıntı var.
</p>
</CardContent>
</Card>
{/* ── 3. Dormant payers ───────────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
3. Dormant payers active sub var, son 14+ gün decode yok
</CardDescription>
</CardHeader>
<CardContent>
{dormant.length === 0 ? (
<p className="text-sm text-muted-foreground">Yok herkes aktif.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>İsim</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Son decode</TableHead>
<TableHead className="text-right">Lifetime</TableHead>
<TableHead className="text-right">Sub. bitiş</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dormant.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email}
</Link>
</TableCell>
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
<TableCell>
<Badge variant="outline">{u.planName ?? "—"}</Badge>
</TableCell>
<TableCell className="text-right text-xs">
{u.daysSinceLastDecode}g önce
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{u.lifetimeDecodes}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{u.subscriptionEndsAt
? u.subscriptionEndsAt.toISOString().slice(0, 10)
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ── 4. Empty-handed payers ──────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
4. Empty-handed payers active sub, 0 lifetime decode (onboarding kırık)
</CardDescription>
</CardHeader>
<CardContent>
{empty.length === 0 ? (
<p className="text-sm text-muted-foreground">Yok paying herkes en az bir sorgu yapmış.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>İsim</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Sub. başlangıç</TableHead>
<TableHead className="text-right">Süre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{empty.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email}
</Link>
</TableCell>
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
<TableCell>
<Badge variant="outline">{u.planName ?? "—"}</Badge>
</TableCell>
<TableCell className="text-right text-xs">
{u.subscriptionStartedAt
? u.subscriptionStartedAt.toISOString().slice(0, 10)
: "—"}
</TableCell>
<TableCell className="text-right text-xs">
{u.daysSinceSubscriptionStart != null
? `${u.daysSinceSubscriptionStart}g`
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ── 5. Trial → paid funnel ──────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
5. Trial paid funnel trial dönemi decode sayısı vs conversion (son 90g)
</CardDescription>
</CardHeader>
<CardContent>
{funnel.length === 0 ? (
<p className="text-sm text-muted-foreground">Trial veri yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Decode bucket</TableHead>
<TableHead className="text-right">Trial user</TableHead>
<TableHead className="text-right">Converted</TableHead>
<TableHead className="text-right">Conversion oranı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{funnel.map((b) => (
<TableRow key={b.decodeBucket}>
<TableCell className="font-mono text-xs">{b.decodeBucket}</TableCell>
<TableCell className="text-right tabular-nums">
{b.trialUsers.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums">
{b.converted.toLocaleString("tr-TR")}
</TableCell>
<TableCell
className={`text-right tabular-nums ${
b.conversionRate >= 0.3
? "text-emerald-600"
: b.conversionRate >= 0.1
? ""
: "text-muted-foreground"
}`}
>
{pct(b.conversionRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
"X decode yapan %Y dönüyor" activation metric. Trial'da hangi sayı "aha moment".
</p>
</CardContent>
</Card>
{/* ── 6. Retention cohort ─────────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
6. Decode başarısı → retention (ilk hafta success var/yok vs 30g sonrası aktiflik)
</CardDescription>
</CardHeader>
<CardContent>
{cohorts.length === 0 ? (
<p className="text-sm text-muted-foreground">Mature olmuş cohort yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cohort</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">İlk hafta success</TableHead>
<TableHead className="text-right">İlk hafta success'siz</TableHead>
<TableHead className="text-right">Retention lift</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cohorts.map((c) => {
const hadRate =
c.hadFirstWeekSuccess > 0
? c.hadFirstWeekSuccessActive30d / c.hadFirstWeekSuccess
: 0;
const nohRate =
c.noFirstWeekSuccess > 0
? c.noFirstWeekSuccessActive30d / c.noFirstWeekSuccess
: 0;
return (
<TableRow key={c.cohort}>
<TableCell className="font-mono text-xs">{c.cohort}</TableCell>
<TableCell className="text-right tabular-nums">
{c.signupCount}
</TableCell>
<TableCell className="text-right text-xs">
{c.hadFirstWeekSuccessActive30d}/{c.hadFirstWeekSuccess}{" "}
<span className="text-emerald-600">({pct(hadRate)})</span>
</TableCell>
<TableCell className="text-right text-xs">
{c.noFirstWeekSuccessActive30d}/{c.noFirstWeekSuccess}{" "}
<span className="text-muted-foreground">({pct(nohRate)})</span>
</TableCell>
<TableCell
className={`text-right tabular-nums ${
c.retentionLift > 0.1 ? "text-emerald-600" : ""
}`}
>
{c.retentionLift > 0 ? "+" : ""}
{(c.retentionLift * 100).toFixed(1)}pp
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ── 7. Power users ──────────────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>7. Top 20 power user (son 30g decode hacmi)</CardDescription>
</CardHeader>
<CardContent>
{powerUsers.length === 0 ? (
<p className="text-sm text-muted-foreground">Veri yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Decode</TableHead>
<TableHead className="text-right">Brand çeşitliliği</TableHead>
<TableHead>Top brand</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{powerUsers.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">{u.planName ?? "—"}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{u.decodeCount.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums">
{u.uniqueBrands}
</TableCell>
<TableCell className="font-mono text-xs">
{u.topBrand ?? "—"}{" "}
<span className="text-muted-foreground">
({u.topBrandCount})
</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(u.successRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ── 8. Tier mismatch ────────────────────────────────────────────── */}
<Card>
<CardHeader>
<CardDescription>
8. Tier mismatch brand-spesifik tier ama atanmış olmayan brand'lerde 3+ sorgu (upsell adayı)
</CardDescription>
</CardHeader>
<CardContent>
{mismatch.length === 0 ? (
<p className="text-sm text-muted-foreground">Mismatch yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Atanmış brand'ler</TableHead>
<TableHead className="text-right">Off-brand</TableHead>
<TableHead className="text-right">Off oranı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mismatch.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">{u.planName}</Badge>
</TableCell>
<TableCell className="text-xs">
<div className="flex flex-wrap gap-1">
{u.assignedBrandSlugs.map((b) => (
<Badge key={b} variant="secondary" className="font-mono">
{b}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{u.offBrandDecodes}/{u.totalDecodes}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(u.offBrandRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}
function pct(v: number): string {
return `${(v * 100).toFixed(1)}%`;
}

View File

@@ -87,20 +87,28 @@ export default async function VinDecodePage({
{RANGE_LABEL[range]} · {health.totalCount.toLocaleString("tr-TR")} sorgu
</p>
</div>
<div className="flex gap-1">
{ALL_RANGES.map((r) => (
<Link
key={r}
href={`?range=${r}`}
className={buttonVariants({
variant: r === range ? "default" : "outline",
size: "sm",
})}
scroll={false}
>
{r}
</Link>
))}
<div className="flex items-center gap-2">
<div className="flex gap-1">
{ALL_RANGES.map((r) => (
<Link
key={r}
href={`?range=${r}`}
className={buttonVariants({
variant: r === range ? "default" : "outline",
size: "sm",
})}
scroll={false}
>
{r}
</Link>
))}
</div>
<Link
href="/projects/sase/vin-decode/business"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Business
</Link>
</div>
</div>