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:
@@ -8,6 +8,8 @@ import { ManageBillingButton } from "@/components/settings/manage-billing-button
|
||||
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
||||
import { BillingDetailsForm } from "@/components/settings/billing-details-form";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
@@ -17,10 +19,11 @@ function describeLimit(n: number, unit: string): string {
|
||||
return n === UNLIMITED ? `Unlimited ${unit}` : `${n} ${unit}`;
|
||||
}
|
||||
|
||||
export default async function BillingPage({ searchParams }: { searchParams: Promise<{ checkout?: string }> }) {
|
||||
export default async function BillingPage({ searchParams }: { searchParams: Promise<{ checkout?: string; upgrade?: string }> }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const { checkout } = await searchParams;
|
||||
const { checkout, upgrade } = await searchParams;
|
||||
const upgradeFeature = FEATURE_DEFINITIONS.find((f) => f.key === upgrade);
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
|
||||
@@ -66,6 +69,14 @@ export default async function BillingPage({ searchParams }: { searchParams: Prom
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{upgradeFeature && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Upgrading unlocks <strong>{upgradeFeature.label}</strong> — {upgradeFeature.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{checkout === "success" && (
|
||||
<div className="rounded-lg border border-primary/40 bg-primary/5 p-4 text-sm">
|
||||
Payment received — your plan updates within a few seconds as Stripe confirms it. Refresh if it doesn't show up right away.
|
||||
|
||||
@@ -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>"See Pro plans" 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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, featureUpgradeClicks } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
const Schema = z.object({
|
||||
featureKey: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
// Fired (best-effort, fire-and-forget from the client) when a user clicks
|
||||
// "I'm interested" on a locked-feature upgrade dialog — purely a signal for
|
||||
// Admin -> Insights ("most-requested locked features"), not a purchase or
|
||||
// a support ticket. Never blocks the redirect to billing on the client.
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const limited = await applyRateLimit(`rl:upgrade-interest:${session!.user.id}`, 20, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const parsed = Schema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.insert(featureUpgradeClicks).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session!.user.id,
|
||||
featureKey: parsed.data.featureKey,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
}
|
||||
Reference in New Issue
Block a user