fix: standardize locked-vs-hidden treatment across all 9 per-tier gated features (v0.79.0)

Rule, applied consistently everywhere via a new isFeatureAvailableAnyTier() helper: if a feature is enabled on at least one tier, it stays visible for locked-out viewers with a small "Pro" badge and opens an upgrade prompt on click; if a feature is disabled on every tier, it hides entirely, since there's no upgrade path to point at.

Covers: recipe variations, meal/drink pairings, nutrition estimation, Markdown export (5 call sites), weekly nutrition, import from URL, import from photo, and the Instacart grocery-delivery menu item. Previously inconsistent — some hid outright, one showed a lock icon overlapping its own icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 13:33:04 +02:00
parent 666d280a4c
commit 1dd8abfd52
25 changed files with 316 additions and 99 deletions
+44 -25
View File
@@ -44,7 +44,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";
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
@@ -118,6 +118,16 @@ export default async function RecipePage({ params }: Params) {
nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier],
markdownExport: !featureFlags.markdown_export[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
// "Pro" upsell) even when the viewer's own tier lacks it.
const available = {
variations: isFeatureAvailableAnyTier(featureFlags, "recipe_variations"),
drinkPairing: isFeatureAvailableAnyTier(featureFlags, "drink_pairing"),
mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"),
nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"),
markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"),
};
const isOwner = recipe.authorId === session.user.id;
@@ -197,8 +207,8 @@ export default async function RecipePage({ params }: Params) {
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
<>
{!locked.mealPairing && <MealPairingButton recipeId={id} locked={false} />}
{!locked.drinkPairing && <DrinkPairingButton recipeId={id} locked={false} />}
{available.mealPairing && <MealPairingButton recipeId={id} locked={locked.mealPairing} />}
{available.drinkPairing && <DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />}
</>
)}
{recipe.visibility === "public" && (
@@ -232,31 +242,33 @@ export default async function RecipePage({ params }: Params) {
ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))}
/>
)}
<VariationsButton
recipeId={id}
baseServings={recipe.baseServings}
difficulty={recipe.difficulty}
prepMins={recipe.prepMins}
cookMins={recipe.cookMins}
ingredients={recipe.ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: ing.order,
}))}
steps={recipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
}))}
locked={locked.variations}
/>
{available.variations && (
<VariationsButton
recipeId={id}
baseServings={recipe.baseServings}
difficulty={recipe.difficulty}
prepMins={recipe.prepMins}
cookMins={recipe.cookMins}
ingredients={recipe.ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: ing.order,
}))}
steps={recipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
}))}
locked={locked.variations}
/>
)}
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
<PrintButton recipeId={id} />
{!locked.markdownExport && (
{available.markdownExport && (
<ExportMarkdownButton
markdown={recipeToMarkdown({
title: recipe.title,
@@ -272,6 +284,7 @@ export default async function RecipePage({ params }: Params) {
batchDishes: recipe.batchDishes,
})}
filename={recipe.title}
locked={locked.markdownExport}
/>
)}
{isOwner && (
@@ -430,7 +443,13 @@ export default async function RecipePage({ params }: Params) {
order: ing.order,
}))}
/>
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} estimateEnabled={!locked.nutritionEstimation} />
<NutritionPanel
recipeId={id}
initialData={recipe.nutritionData}
initialManual={recipe.nutritionManual}
estimateAvailable={available.nutritionEstimation}
estimateLocked={locked.nutritionEstimation}
/>
</div>
)}