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
+6 -3
View File
@@ -18,7 +18,7 @@ import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
import { EmptyState } from "@/components/shared/empty-state";
import { collectionToMarkdown } from "@/lib/markdown/collection";
import { getMessages } from "@/lib/i18n/server";
@@ -33,7 +33,9 @@ export default async function CollectionPage({ params }: Params) {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier];
const featureFlags = await getFeatureFlagMatrix();
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
@@ -85,13 +87,14 @@ export default async function CollectionPage({ params }: Params) {
} />
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
</Tooltip>
{canExportMarkdown && <ExportMarkdownButton
{markdownExportAvailable && <ExportMarkdownButton
markdown={collectionToMarkdown({
name: col.name,
description: col.description,
recipes: recipeList,
})}
filename={col.name}
locked={markdownExportLocked}
/>}
</>
)}
+8 -5
View File
@@ -12,7 +12,7 @@ import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
import { getMessages, formatMessage } from "@/lib/i18n/server";
@@ -62,8 +62,10 @@ export default async function MealPlanPage({
const msgs = getMessages((session.user as { locale?: string }).locale);
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const featureFlags = await getFeatureFlagMatrix();
const canExportMarkdown = featureFlags.markdown_export[viewerTier];
const canSeeWeeklyNutrition = featureFlags.weekly_nutrition[viewerTier];
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
const weeklyNutritionLocked = !featureFlags.weekly_nutrition[viewerTier];
const weeklyNutritionAvailable = isFeatureAvailableAnyTier(featureFlags, "weekly_nutrition");
const monday = getMonday(week);
const weekStart = toDateStr(monday);
@@ -162,10 +164,11 @@ export default async function MealPlanPage({
} />
<TooltipContent>{msgs.common.print}</TooltipContent>
</Tooltip>
{canExportMarkdown && (
{markdownExportAvailable && (
<ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`}
locked={markdownExportLocked}
/>
)}
<Tooltip>
@@ -180,7 +183,7 @@ export default async function MealPlanPage({
</div>
</TooltipProvider>
{canSeeWeeklyNutrition && <WeeklyNutritionBar weekStart={weekStart} />}
{weeklyNutritionAvailable && <WeeklyNutritionBar weekStart={weekStart} locked={weeklyNutritionLocked} />}
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
{sharedMemberships.length > 0 && (
+5 -3
View File
@@ -10,7 +10,7 @@ import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getPublicUrl } from "@/lib/storage";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
export const metadata: Metadata = {};
@@ -18,7 +18,9 @@ export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier];
const featureFlags = await getFeatureFlagMatrix();
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
const [items, candidateRecipes, cookedDishes] = await Promise.all([
db.query.pantryItems.findMany({
@@ -78,7 +80,7 @@ export default async function PantryPage() {
return (
<div className="space-y-6">
<PantryPageHeader items={mappedItems} canExportMarkdown={canExportMarkdown} />
<PantryPageHeader items={mappedItems} markdownExportAvailable={markdownExportAvailable} markdownExportLocked={markdownExportLocked} />
<ExpiringLeftovers leftovers={leftovers} />
<ExpiringSoonSuggestions suggestions={suggestions} />
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
+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>
)}
+4 -3
View File
@@ -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>
+6 -4
View File
@@ -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} />
@@ -16,7 +16,7 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { shoppingListToMarkdown } from "@/lib/markdown/shopping-list";
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 }> };
@@ -40,8 +40,11 @@ export default async function ShoppingListPage({ params }: Params) {
const canEdit = canWriteShoppingList(access.role);
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const featureFlags = await getFeatureFlagMatrix();
const canExportMarkdown = featureFlags.markdown_export[viewerTier];
const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart" && featureFlags.grocery_delivery[viewerTier];
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
const instacartProviderConfigured = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart";
const instacartLocked = !featureFlags.grocery_delivery[viewerTier];
const instacartAvailable = instacartProviderConfigured && isFeatureAvailableAnyTier(featureFlags, "grocery_delivery");
return (
<div className="space-y-6 max-w-2xl">
@@ -55,7 +58,7 @@ export default async function ShoppingListPage({ params }: Params) {
</div>
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
<GroceryExportButton listId={id} instacartAvailable={instacartAvailable} instacartLocked={instacartLocked} />
{access.role === "owner" && (
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
)}
@@ -67,10 +70,11 @@ export default async function ShoppingListPage({ params }: Params) {
} />
<TooltipContent>{m.common.print}</TooltipContent>
</Tooltip>
{canExportMarkdown && (
{markdownExportAvailable && (
<ExportMarkdownButton
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
filename={list.name}
locked={markdownExportLocked}
/>
)}
{access.role === "owner" && (