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:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.81.0 — 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.
|
||||||
|
|
||||||
## 0.80.0 — 2026-07-24 16:45
|
## 0.80.0 — 2026-07-24 16:45
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-1
@@ -117,7 +117,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
|
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
|
||||||
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
|
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
|
||||||
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 10 capabilities by tier: recipe_variations/drink_pairing/meal_pairing/version_history (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
|
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 10 capabilities by tier: recipe_variations/drink_pairing/meal_pairing/version_history (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
|
||||||
| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 10 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, recipe version history, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). `UpgradeDialog` itself now shows a per-feature tagline + bullet list (`FEATURE_COPY` in the dialog component) instead of one generic sentence for every feature. | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/{pro-badge,upgrade-dialog}.tsx` |
|
| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 10 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, recipe version history, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). `UpgradeDialog` itself shows a per-feature tagline + bullet list (`FEATURE_COPY` in the dialog component) instead of one generic sentence for every feature. | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/{pro-badge,upgrade-dialog}.tsx` |
|
||||||
|
| Upgrade-interest tracking (new 2026-07-24) | Exists | `UpgradeDialog`'s CTA ("See Pro plans") now reroutes to `Settings → Billing?upgrade=<key>` (which shows a feature-specific banner) instead of a generic support-ticket prefill, and fires a best-effort `POST /api/v1/users/me/upgrade-interest` beacon (`keepalive: true`, survives the immediate navigation) recording which feature triggered it. Admin → Insights has a new "Most-requested locked features" chart, grouped by feature key, last 30 days. | `packages/db/src/schema/feature-flags.ts` (`featureUpgradeClicks`), `apps/web/app/api/v1/users/me/upgrade-interest/route.ts`, `apps/web/app/admin/insights/page.tsx` |
|
||||||
| AI-generate dialog's Photo tab bypassed the photo-import gate (closed 2026-07-24) | Exists | A second, unguarded front door into `recipe_import_photo` — the tab in `AiGenerateDialog` had no tier check at all, while the dedicated `PhotoImportButton` on `/recipes/new` did. Server-side `requireFeatureEnabledResponse` on the API route meant a fully-disabled feature still 403'd, but a viewer without the tier could reach the tab, fill it in, and hit a dead-end error instead of an upgrade prompt. Now the tab hides/locks in step with `PhotoImportButton`, threaded from `RecipesHeader`. | `apps/web/components/recipe/ai-generate-dialog.tsx`, `apps/web/components/recipe/recipes-header.tsx` |
|
| AI-generate dialog's Photo tab bypassed the photo-import gate (closed 2026-07-24) | Exists | A second, unguarded front door into `recipe_import_photo` — the tab in `AiGenerateDialog` had no tier check at all, while the dedicated `PhotoImportButton` on `/recipes/new` did. Server-side `requireFeatureEnabledResponse` on the API route meant a fully-disabled feature still 403'd, but a viewer without the tier could reach the tab, fill it in, and hit a dead-end error instead of an upgrade prompt. Now the tab hides/locks in step with `PhotoImportButton`, threaded from `RecipesHeader`. | `apps/web/components/recipe/ai-generate-dialog.tsx`, `apps/web/components/recipe/recipes-header.tsx` |
|
||||||
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
|
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
|
||||||
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
|
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { ManageBillingButton } from "@/components/settings/manage-billing-button
|
|||||||
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
||||||
import { BillingDetailsForm } from "@/components/settings/billing-details-form";
|
import { BillingDetailsForm } from "@/components/settings/billing-details-form";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -17,10 +19,11 @@ function describeLimit(n: number, unit: string): string {
|
|||||||
return n === UNLIMITED ? `Unlimited ${unit}` : `${n} ${unit}`;
|
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() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return null;
|
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);
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
@@ -66,6 +69,14 @@ export default async function BillingPage({ searchParams }: { searchParams: Prom
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<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" && (
|
{checkout === "success" && (
|
||||||
<div className="rounded-lg border border-primary/40 bg-primary/5 p-4 text-sm">
|
<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.
|
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 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { BarChart } from "@/components/admin/charts/bar-chart";
|
import { BarChart } from "@/components/admin/charts/bar-chart";
|
||||||
import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart";
|
import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart";
|
||||||
import { requireFullAdminPage } from "@/lib/require-admin-page";
|
import { requireFullAdminPage } from "@/lib/require-admin-page";
|
||||||
|
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -65,13 +66,18 @@ export default async function AdminInsightsPage() {
|
|||||||
.from(userUsage)
|
.from(userUsage)
|
||||||
.groupBy(userUsage.month),
|
.groupBy(userUsage.month),
|
||||||
db.select({ status: supportTickets.status, n: sql<number>`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status),
|
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) {
|
for (const r of results) {
|
||||||
if (r.status === "rejected") console.error("[admin/insights] query failed", r.reason);
|
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 : []
|
r.status === "fulfilled" ? r.value : []
|
||||||
) as [
|
) as [
|
||||||
{ day: string; n: number }[],
|
{ day: string; n: number }[],
|
||||||
@@ -80,6 +86,7 @@ export default async function AdminInsightsPage() {
|
|||||||
{ visibility: "private" | "unlisted" | "public" | "followers"; n: number }[],
|
{ visibility: "private" | "unlisted" | "public" | "followers"; n: number }[],
|
||||||
{ month: string; n: number }[],
|
{ month: string; n: number }[],
|
||||||
{ status: "open" | "triaged" | "closed"; n: number }[],
|
{ status: "open" | "triaged" | "closed"; n: number }[],
|
||||||
|
{ featureKey: string; n: number }[],
|
||||||
];
|
];
|
||||||
|
|
||||||
const signupByDay = new Map(signupRows.map((r) => [r.day, r.n]));
|
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 statusByKey = new Map(ticketRows.map((r) => [r.status, r.n]));
|
||||||
const statusData = STATUS_ORDER.map((s) => ({ label: s, values: [statusByKey.get(s) ?? 0] }));
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -178,6 +190,20 @@ export default async function AdminInsightsPage() {
|
|||||||
<BarChart data={statusData} seriesLabels={["Tickets"]} />
|
<BarChart data={statusData} seriesLabels={["Tickets"]} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</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 });
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useRouter } from "next/navigation";
|
||||||
import { Sparkles, Check } from "lucide-react";
|
import { Sparkles, Check } from "lucide-react";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -112,6 +111,21 @@ export function UpgradeDialog({
|
|||||||
featureLabel: string;
|
featureLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const copy = FEATURE_COPY[featureKey];
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -139,12 +153,9 @@ export function UpgradeDialog({
|
|||||||
Included on the Pro plan (€4.99/mo).
|
Included on the Pro plan (€4.99/mo).
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter className="sm:justify-start">
|
<DialogFooter className="sm:justify-start">
|
||||||
<Link
|
<Button onClick={handleUpgradeClick}>
|
||||||
href={`/support?upgrade=${encodeURIComponent(featureKey)}`}
|
See Pro plans
|
||||||
className={cn(buttonVariants({ variant: "default" }))}
|
</Button>
|
||||||
>
|
|
||||||
I'm interested — let us know
|
|
||||||
</Link>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.80.0",
|
||||||
date: "2026-07-24 16:45",
|
date: "2026-07-24 16:45",
|
||||||
|
|||||||
@@ -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: "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: "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: "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: "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: "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 } } } } });
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.80.0",
|
"version": "0.81.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.80.0",
|
"version": "0.81.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE "feature_upgrade_clicks" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"feature_key" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "feature_upgrade_clicks" ADD CONSTRAINT "feature_upgrade_clicks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -456,6 +456,13 @@
|
|||||||
"when": 1784885885146,
|
"when": 1784885885146,
|
||||||
"tag": "0064_thick_drax",
|
"tag": "0064_thick_drax",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 65,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784894764837,
|
||||||
|
"tag": "0065_cooing_carnage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -18,3 +18,17 @@ export const featureFlags = pgTable("feature_flags", {
|
|||||||
export const featureFlagsRelations = relations(featureFlags, ({ one }) => ({
|
export const featureFlagsRelations = relations(featureFlags, ({ one }) => ({
|
||||||
updatedBy: one(users, { fields: [featureFlags.updatedById], references: [users.id] }),
|
updatedBy: one(users, { fields: [featureFlags.updatedById], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// One row per "I'm interested" click from a locked-feature upgrade dialog —
|
||||||
|
// lets admins see which locked features are actually driving upgrade
|
||||||
|
// interest (Admin -> Insights), not just that upgrades happen.
|
||||||
|
export const featureUpgradeClicks = pgTable("feature_upgrade_clicks", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
featureKey: text("feature_key").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const featureUpgradeClicksRelations = relations(featureUpgradeClicks, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [featureUpgradeClicks.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user