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
+28 -8
View File
@@ -19,6 +19,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { AiGenerateDialog } from "./ai-generate-dialog";
import { UrlImportDialog } from "./url-import-dialog";
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
import { ProBadge } from "@/components/premium/pro-badge";
function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [local, setLocal] = useState(value);
@@ -88,7 +90,8 @@ export function RecipesHeader({
initialBatchCook = "",
initialRecipeType = "",
sharedUrl,
showImportUrl = true,
importUrlAvailable = true,
importUrlLocked = false,
}: {
count: number;
initialQuery?: string;
@@ -104,16 +107,21 @@ export function RecipesHeader({
* Auto-opens the import dialog pre-filled instead of requiring the user
* to paste the link again. */
sharedUrl?: string;
/** Tier feature flag (recipe_import_url) — hides the button and dialog
* entirely when off, not just disabled. */
showImportUrl?: boolean;
/** Tier feature flag (recipe_import_url) disabled for every tier — hides
* the button and dialog entirely, since there's no upgrade path. */
importUrlAvailable?: boolean;
/** Available on some tier but not the viewer's — button still shows
* (with a "Pro" upsell) and opens an upgrade prompt instead of the
* import dialog. */
importUrlLocked?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const tRecipe = useTranslations("recipe");
const [aiOpen, setAiOpen] = useState(false);
const [urlOpen, setUrlOpen] = useState(!!sharedUrl);
const [urlOpen, setUrlOpen] = useState(!!sharedUrl && !importUrlLocked);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [, startTransition] = useTransition();
@@ -164,10 +172,16 @@ export function RecipesHeader({
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
{showImportUrl && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setUrlOpen(true)}>
{importUrlAvailable && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => (importUrlLocked ? setUpgradeOpen(true) : setUrlOpen(true))}
>
<Link2 className="h-4 w-4" />
{t("importUrl")}
{importUrlLocked && <ProBadge />}
</Button>
)}
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
@@ -327,7 +341,13 @@ export function RecipesHeader({
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl && !importUrlLocked} />
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="recipe_import_url"
featureLabel="Import from URL"
/>
</>
);
}