feat: locked-feature upgrade prompts route to billing + track upgrade interest (v0.81.0)

UpgradeDialog's CTA now sends users to Settings > Billing (with a feature-specific banner) instead of a generic support-ticket prefill, and fires a best-effort tracking beacon recording which feature triggered the click. New feature_upgrade_clicks table + Admin > Insights chart ("Most-requested locked features") gives admins visibility into which locked features actually drive upgrade interest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 14:12:12 +02:00
parent 1fe379bcdc
commit ce7741c0b6
14 changed files with 6392 additions and 17 deletions
+28 -2
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import { db, users, recipes, userUsage, supportTickets, gte, sql } from "@epicure/db";
import { db, users, recipes, userUsage, supportTickets, featureUpgradeClicks, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { BarChart } from "@/components/admin/charts/bar-chart";
import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart";
import { requireFullAdminPage } from "@/lib/require-admin-page";
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
export const metadata: Metadata = {};
@@ -65,13 +66,18 @@ export default async function AdminInsightsPage() {
.from(userUsage)
.groupBy(userUsage.month),
db.select({ status: supportTickets.status, n: sql<number>`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status),
db
.select({ featureKey: featureUpgradeClicks.featureKey, n: sql<number>`count(*)::int` })
.from(featureUpgradeClicks)
.where(gte(featureUpgradeClicks.createdAt, since))
.groupBy(featureUpgradeClicks.featureKey),
]);
for (const r of results) {
if (r.status === "rejected") console.error("[admin/insights] query failed", r.reason);
}
const [signupRows, recipeRows, tierRows, visibilityRows, usageRows, ticketRows] = results.map((r) =>
const [signupRows, recipeRows, tierRows, visibilityRows, usageRows, ticketRows, upgradeClickRows] = results.map((r) =>
r.status === "fulfilled" ? r.value : []
) as [
{ day: string; n: number }[],
@@ -80,6 +86,7 @@ export default async function AdminInsightsPage() {
{ visibility: "private" | "unlisted" | "public" | "followers"; n: number }[],
{ month: string; n: number }[],
{ status: "open" | "triaged" | "closed"; n: number }[],
{ featureKey: string; n: number }[],
];
const signupByDay = new Map(signupRows.map((r) => [r.day, r.n]));
@@ -111,6 +118,11 @@ export default async function AdminInsightsPage() {
const statusByKey = new Map(ticketRows.map((r) => [r.status, r.n]));
const statusData = STATUS_ORDER.map((s) => ({ label: s, values: [statusByKey.get(s) ?? 0] }));
const FEATURE_LABEL = new Map<string, string>(FEATURE_DEFINITIONS.map((f) => [f.key, f.label]));
const upgradeClickData = [...upgradeClickRows]
.sort((a, b) => b.n - a.n)
.map((r) => ({ label: FEATURE_LABEL.get(r.featureKey) ?? r.featureKey, values: [r.n] }));
return (
<div className="space-y-6">
<div>
@@ -178,6 +190,20 @@ export default async function AdminInsightsPage() {
<BarChart data={statusData} seriesLabels={["Tickets"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Most-requested locked features</CardTitle>
<CardDescription>&quot;See Pro plans&quot; clicks from a locked-feature upgrade prompt, last {DAYS} days</CardDescription>
</CardHeader>
<CardContent>
{upgradeClickData.length > 0 ? (
<BarChart data={upgradeClickData} seriesLabels={["Clicks"]} />
) : (
<p className="text-sm text-muted-foreground">No upgrade-prompt clicks yet in this window.</p>
)}
</CardContent>
</Card>
</div>
</div>
);