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
@@ -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" && (