feat: per-tier feature toggles for recipe variations/pairings (v0.50.0)

Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).

Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).

The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-18 23:35:12 +02:00
parent 12c2ec213a
commit 2f3ba14093
24 changed files with 5945 additions and 19 deletions
+13 -3
View File
@@ -41,6 +41,7 @@ import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { recipeToMarkdown } from "@/lib/markdown/recipe";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
@@ -70,7 +71,7 @@ export default async function RecipePage({ params }: Params) {
const DIETARY_LABELS = m.recipe.dietary;
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags] = await Promise.all([
db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
@@ -100,10 +101,18 @@ export default async function RecipePage({ params }: Params) {
orderBy: desc(cookingHistory.cookedAt),
columns: { batchDishId: true, cookedAt: true },
}),
getFeatureFlagMatrix(),
]);
if (!recipe) notFound();
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const locked = {
variations: !featureFlags.recipe_variations[viewerTier],
drinkPairing: !featureFlags.drink_pairing[viewerTier],
mealPairing: !featureFlags.meal_pairing[viewerTier],
};
const isOwner = recipe.authorId === session.user.id;
const dishCookedAtMap = new Map<string, string>();
@@ -176,8 +185,8 @@ export default async function RecipePage({ params }: Params) {
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
<>
<MealPairingButton recipeId={id} />
<DrinkPairingButton recipeId={id} />
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
</>
)}
{recipe.visibility === "public" && (
@@ -229,6 +238,7 @@ export default async function RecipePage({ params }: Params) {
timerSeconds: s.timerSeconds,
order: s.order,
}))}
locked={locked.variations}
/>
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
+13 -1
View File
@@ -5,14 +5,25 @@ import { db, supportTickets, eq, desc } from "@epicure/db";
import { SupportManager } from "@/components/support/support-manager";
import { getMessages } from "@/lib/i18n/server";
import { getPublicUrl } from "@/lib/storage";
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
export const metadata: Metadata = {};
export default async function SupportPage() {
export default async function SupportPage({
searchParams,
}: {
searchParams: Promise<{ upgrade?: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const { upgrade } = await searchParams;
const upgradeFeature = FEATURE_DEFINITIONS.find((f) => f.key === upgrade);
const prefill = upgradeFeature
? { type: "suggestion" as const, title: `Interested in upgrading for: ${upgradeFeature.label}` }
: undefined;
const rows = await db.query.supportTickets.findMany({
where: eq(supportTickets.userId, session.user.id),
orderBy: desc(supportTickets.createdAt),
@@ -26,6 +37,7 @@ export default async function SupportPage() {
<p className="text-muted-foreground text-sm mt-1">{m.support.subtitle}</p>
</div>
<SupportManager
prefill={prefill}
initialTickets={rows.map((r) => ({
id: r.id,
type: r.type,