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
+13 -2
View File
@@ -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&apos;t show up right away.
+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>
);
@@ -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 });
}
+20 -9
View File
@@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, Check } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -112,6 +111,21 @@ export function UpgradeDialog({
featureLabel: string;
}) {
const copy = FEATURE_COPY[featureKey];
const router = useRouter();
function handleUpgradeClick() {
// keepalive lets this best-effort beacon survive the navigation that
// follows immediately after — a plain fetch would otherwise risk being
// aborted mid-flight by the router.push below.
fetch("/api/v1/users/me/upgrade-interest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featureKey }),
keepalive: true,
}).catch(() => {});
onOpenChange(false);
router.push(`/settings/billing?upgrade=${encodeURIComponent(featureKey)}`);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -139,12 +153,9 @@ export function UpgradeDialog({
Included on the Pro plan (4.99/mo).
</p>
<DialogFooter className="sm:justify-start">
<Link
href={`/support?upgrade=${encodeURIComponent(featureKey)}`}
className={cn(buttonVariants({ variant: "default" }))}
>
I&apos;m interested let us know
</Link>
<Button onClick={handleUpgradeClick}>
See Pro plans
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.80.0";
export const APP_VERSION = "0.81.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.81.0",
date: "2026-07-24 17:15",
added: [
"Locked-feature upgrade prompts now route straight to Settings > Billing (with a banner naming the feature you were trying to use) instead of a generic support-ticket form.",
"Every upgrade-prompt click is now recorded (feature key only, best-effort) so Admin > Insights can show which locked features are actually driving upgrade interest, in a new \"Most-requested locked features\" chart.",
],
},
{
version: "0.80.0",
date: "2026-07-24 16:45",
+1
View File
@@ -570,6 +570,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/billing-details", summary: "Get your billing/invoice details", description: "Full name, address, and phone used on invoices — separate from your display name.", security, responses: { 200: { description: "Billing details or null", content: { "application/json": { schema: z.object({ data: BillingDetailsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/billing-details", summary: "Set your billing/invoice details", description: "fullName is required; addressLine1/2, city, postalCode, country, and phone are all optional.", security, request: { body: { content: { "application/json": { schema: UpdateBillingDetailsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/me/upgrade-interest", summary: "Record interest in a locked feature", description: "Fired when you click \"See Pro plans\" on a locked-feature upgrade prompt — a signal for admin insights (most-requested locked features), not a purchase. Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ featureKey: z.string().min(1).max(100) }) } }, required: true } }, responses: { 201: { description: "Recorded", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/onboarding", summary: "Get your post-signup onboarding status", security, responses: { 200: { description: "Status", content: { "application/json": { schema: z.object({ data: OnboardingStatusRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/me/onboarding", summary: "Complete (or skip) the post-signup onboarding wizard", description: "Marks onboarding done regardless of what's in the body — call with an empty body to skip entirely. dietaryTags/allergens are only written when provided.", security, request: { body: { content: { "application/json": { schema: CompleteOnboardingRef } } } }, responses: { 200: { description: "Completed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.80.0",
"version": "0.81.0",
"private": true,
"scripts": {
"dev": "next dev",