feat: version history as 10th feature flag, richer upgrade prompts, close unguarded photo-import tab (v0.80.0)

Recipe version history is now gated like the other 9 per-tier features (enabled by default, locked-vs-hidden treatment via isFeatureAvailableAnyTier). UpgradeDialog now shows a tagline + concrete bullet list per feature instead of one generic sentence.

Also closes a real gap: the AI-generate dialog's Photo tab hit /api/v1/ai/import-photo directly with no tier check in the UI (server route was already correctly gated) — a locked-out user could fill it in and hit a dead-end error instead of an upgrade prompt. Now threaded through the same lock/hide props as the dedicated PhotoImportButton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 14:04:27 +02:00
parent 1dd8abfd52
commit 1fe379bcdc
15 changed files with 240 additions and 32 deletions
+22 -17
View File
@@ -117,6 +117,7 @@ export default async function RecipePage({ params }: Params) {
mealPairing: !featureFlags.meal_pairing[viewerTier],
nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier],
markdownExport: !featureFlags.markdown_export[viewerTier],
versionHistory: !featureFlags.version_history[viewerTier],
};
// A feature disabled for every tier has no upgrade path, so it hides
// outright; one enabled for at least one tier still shows (locked, with a
@@ -127,6 +128,7 @@ export default async function RecipePage({ params }: Params) {
mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"),
nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"),
markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"),
versionHistory: isFeatureAvailableAnyTier(featureFlags, "version_history"),
};
const isOwner = recipe.authorId === session.user.id;
@@ -289,23 +291,26 @@ export default async function RecipePage({ params }: Params) {
)}
{isOwner && (
<>
<VersionHistoryButton
recipeId={id}
currentSnapshot={{
title: recipe.title,
description: recipe.description,
ingredients: recipe.ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
})),
steps: recipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
})),
}}
/>
{available.versionHistory && (
<VersionHistoryButton
recipeId={id}
currentSnapshot={{
title: recipe.title,
description: recipe.description,
ingredients: recipe.ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
})),
steps: recipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
})),
}}
locked={locked.versionHistory}
/>
)}
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
+4
View File
@@ -64,6 +64,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
const featureFlags = await getFeatureFlagMatrix();
const importUrlLocked = !featureFlags.recipe_import_url[viewerTier];
const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url");
const importPhotoLocked = !featureFlags.recipe_import_photo[viewerTier];
const importPhotoAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_photo");
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
const sharedUrl = extractSharedUrl({ url, text });
@@ -166,6 +168,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined}
importUrlAvailable={importUrlAvailable}
importUrlLocked={importUrlLocked}
importPhotoAvailable={importPhotoAvailable}
importPhotoLocked={importPhotoLocked}
/>
<RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
@@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string; versionId: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id, versionId } = await params;
// Verify ownership
@@ -31,6 +36,10 @@ export async function GET(req: NextRequest, { params }: Params) {
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id, versionId } = await params;
const body = await req.json() as { action?: string };
@@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeSnapshots } from "@epicure/db";
import { eq, and, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id } = await params;
// Verify ownership