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
+43 -17
View File
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
import { ProBadge } from "@/components/premium/pro-badge";
type NutritionData = {
perServing: {
@@ -20,20 +22,28 @@ interface NutritionPanelProps {
recipeId: string;
initialData?: NutritionData | null;
initialManual?: boolean;
/** AI/USDA estimation feature toggled off for this tier — hides the
* (re-)estimate action. Previously-stored data (manual or a past
* estimate) still displays; there's just no button to refresh it. */
estimateEnabled?: boolean;
/** AI/USDA estimation disabled for every tier — hides the (re-)estimate
* action entirely. Previously-stored data (manual or a past estimate)
* still displays; there's just no button to refresh it. */
estimateAvailable?: boolean;
/** Estimation is available on some tier but not the viewer's — the
* action still shows (with a "Pro" upsell) instead of hiding. */
estimateLocked?: boolean;
}
export function NutritionPanel({ recipeId, initialData, initialManual, estimateEnabled = true }: NutritionPanelProps) {
export function NutritionPanel({ recipeId, initialData, initialManual, estimateAvailable = true, estimateLocked = false }: NutritionPanelProps) {
const t = useTranslations("nutritionPanel");
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
const [manual, setManual] = useState(!!initialManual);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [upgradeOpen, setUpgradeOpen] = useState(false);
async function handleEstimate() {
if (estimateLocked) {
setUpgradeOpen(true);
return;
}
setLoading(true);
setError(null);
try {
@@ -54,13 +64,20 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
}
if (!nutrition && !loading) {
if (!estimateEnabled) return null;
if (!estimateAvailable) return null;
return (
<div className="flex flex-col gap-2">
{error && <p className="text-sm text-destructive">{error}</p>}
<Button variant="outline" onClick={handleEstimate} disabled={loading}>
<Button variant="outline" onClick={handleEstimate} disabled={loading} className="self-start gap-1.5">
{t("estimateButton")}
{estimateLocked && <ProBadge />}
</Button>
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="nutrition_estimation"
featureLabel="Nutrition estimation"
/>
</div>
);
}
@@ -70,16 +87,19 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{t("title")}</CardTitle>
{estimateEnabled && (
<Button
variant="ghost"
size="sm"
onClick={handleEstimate}
disabled={loading}
className="text-xs text-muted-foreground"
>
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
</Button>
{estimateAvailable && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleEstimate}
disabled={loading}
className="text-xs text-muted-foreground gap-1.5"
>
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
{estimateLocked && <ProBadge />}
</Button>
</>
)}
</div>
{manual && !loading && (
@@ -132,6 +152,12 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
</p>
</CardContent>
)}
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="nutrition_estimation"
featureLabel="Nutrition estimation"
/>
</Card>
);
}