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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf, Lock } from "lucide-react";
|
||||
import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide-react";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||
import { ProBadge } from "@/components/premium/pro-badge";
|
||||
|
||||
type Drink = {
|
||||
name: string;
|
||||
@@ -87,12 +88,14 @@ export function DrinkPairingButton({ recipeId, locked = false }: { recipeId: str
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Button variant="ghost" size="icon" onClick={handleOpen} aria-label={t("drinksTooltip")} className="relative">
|
||||
<Button variant="ghost" size="icon" onClick={handleOpen} aria-label={t("drinksTooltip")}>
|
||||
<Wine className="h-4 w-4" />
|
||||
{locked && <Lock className="h-2.5 w-2.5 absolute bottom-1 right-1 text-muted-foreground" />}
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>{t("drinksTooltip")}</TooltipContent>
|
||||
<TooltipContent className="flex items-center gap-1.5">
|
||||
{t("drinksTooltip")}
|
||||
{locked && <ProBadge />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { UtensilsCrossed, Sparkles, Loader2, ChefHat, Salad, Wine, Cake, Sandwich, Soup, Check, Lock } from "lucide-react";
|
||||
import { UtensilsCrossed, Sparkles, Loader2, ChefHat, Salad, Wine, Cake, Sandwich, Soup, Check } from "lucide-react";
|
||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||
import { ProBadge } from "@/components/premium/pro-badge";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -152,13 +153,14 @@ export function MealPairingButton({ recipeId, locked = false }: { recipeId: stri
|
||||
if (pairings.length === 0) suggest();
|
||||
}}
|
||||
aria-label={t("pairMealTooltip")}
|
||||
className="relative"
|
||||
>
|
||||
<UtensilsCrossed className="h-4 w-4" />
|
||||
{locked && <Lock className="h-2.5 w-2.5 absolute bottom-1 right-1 text-muted-foreground" />}
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>{t("pairMealTooltip")}</TooltipContent>
|
||||
<TooltipContent className="flex items-center gap-1.5">
|
||||
{t("pairMealTooltip")}
|
||||
{locked && <ProBadge />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,23 @@ import { Camera, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||
import { ProBadge } from "@/components/premium/pro-badge";
|
||||
|
||||
export function PhotoImportButton() {
|
||||
export function PhotoImportButton({ locked = false }: { locked?: boolean }) {
|
||||
const t = useTranslations("recipe");
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stage, setStage] = useState<"recognizing" | "generating">("recognizing");
|
||||
const stageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
|
||||
function handleClick() {
|
||||
if (locked) {
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
fileRef.current?.click();
|
||||
}
|
||||
|
||||
@@ -88,12 +95,19 @@ export function PhotoImportButton() {
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("importFromPhoto")}
|
||||
{locked && <ProBadge />}
|
||||
</Button>
|
||||
<FakeProgressBar
|
||||
active={loading}
|
||||
durationMs={12000}
|
||||
label={loading ? (stage === "recognizing" ? t("recognizingPhoto") : t("writingRecipe")) : undefined}
|
||||
/>
|
||||
<UpgradeDialog
|
||||
open={upgradeOpen}
|
||||
onOpenChange={setUpgradeOpen}
|
||||
featureKey="recipe_import_photo"
|
||||
featureLabel="Import from photo"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { VariationsDialog } from "./variations-dialog";
|
||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||
import { ProBadge } from "@/components/premium/pro-badge";
|
||||
|
||||
export function VariationsButton({
|
||||
recipeId,
|
||||
@@ -48,7 +48,7 @@ export function VariationsButton({
|
||||
} />
|
||||
<TooltipContent className="flex items-center gap-1.5">
|
||||
{t("variationsTooltip")}
|
||||
{locked && <Badge variant="secondary" className="text-[10px] px-1 py-0 leading-4">Pro</Badge>}
|
||||
{locked && <ProBadge />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
Reference in New Issue
Block a user