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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { auth } from "@/lib/auth/server";
|
||||
import { RecipeForm } from "@/components/recipe/recipe-form";
|
||||
import { NewRecipeHeader } from "@/components/recipe/new-recipe-header";
|
||||
import { PhotoImportButton } from "@/components/recipe/photo-import-button";
|
||||
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
|
||||
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
@@ -12,13 +12,14 @@ export default async function NewRecipePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const viewerTier = (session?.user as { tier?: string } | undefined)?.tier as Tier | undefined ?? "free";
|
||||
const featureFlags = await getFeatureFlagMatrix();
|
||||
const canImportPhoto = featureFlags.recipe_import_photo[viewerTier];
|
||||
const importPhotoLocked = !featureFlags.recipe_import_photo[viewerTier];
|
||||
const importPhotoAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_photo");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<NewRecipeHeader />
|
||||
{canImportPhoto && <PhotoImportButton />}
|
||||
{importPhotoAvailable && <PhotoImportButton locked={importPhotoLocked} />}
|
||||
</div>
|
||||
<RecipeForm />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
||||
import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
import { getFeaturePrefs } from "@/lib/feature-prefs";
|
||||
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
|
||||
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
@@ -62,7 +62,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
const featurePrefs = await getFeaturePrefs(session.user.id);
|
||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
||||
const featureFlags = await getFeatureFlagMatrix();
|
||||
const canImportUrl = featureFlags.recipe_import_url[viewerTier];
|
||||
const importUrlLocked = !featureFlags.recipe_import_url[viewerTier];
|
||||
const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url");
|
||||
|
||||
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
|
||||
const sharedUrl = extractSharedUrl({ url, text });
|
||||
@@ -162,8 +163,9 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
initialTag={tagFilter ?? ""}
|
||||
initialBatchCook={batchCookFilter ?? ""}
|
||||
initialRecipeType={recipeTypeFilter ?? ""}
|
||||
sharedUrl={canImportUrl ? sharedUrl : undefined}
|
||||
showImportUrl={canImportUrl}
|
||||
sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined}
|
||||
importUrlAvailable={importUrlAvailable}
|
||||
importUrlLocked={importUrlLocked}
|
||||
/>
|
||||
<RecipesEmptyState query={query} count={total} />
|
||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||
|
||||
Reference in New Issue
Block a user