feat: version history as 10th feature flag, richer upgrade prompts, close unguarded photo-import tab (v0.80.0)

Recipe version history is now gated like the other 9 per-tier features (enabled by default, locked-vs-hidden treatment via isFeatureAvailableAnyTier). UpgradeDialog now shows a tagline + concrete bullet list per feature instead of one generic sentence.

Also closes a real gap: the AI-generate dialog's Photo tab hit /api/v1/ai/import-photo directly with no tier check in the UI (server route was already correctly gated) — a locked-out user could fill it in and hit a dead-end error instead of an upgrade prompt. Now threaded through the same lock/hide props as the dedicated PhotoImportButton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 14:04:27 +02:00
parent 1dd8abfd52
commit 1fe379bcdc
15 changed files with 240 additions and 32 deletions
+105 -3
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Sparkles } from "lucide-react";
import { Sparkles, Check } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
@@ -13,6 +13,93 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
// Marketing copy per feature — kept client-side (not sourced from
// lib/feature-flags.ts's FEATURE_DEFINITIONS) since that module pulls in
// the DB client for server-only helpers and can't be bundled for the
// browser. Falls back to a generic pitch for any key not listed here.
const FEATURE_COPY: Record<string, { tagline: string; bullets: string[] }> = {
recipe_variations: {
tagline: "Get AI-generated twists on any recipe you save — dietary swaps, flavor changes, or a whole new spin — without starting from scratch.",
bullets: [
"Dietary swaps (vegan, gluten-free, dairy-free, and more)",
"Flavor variations generated from your existing recipe",
"Saved as a new recipe, linked back to the original",
],
},
drink_pairing: {
tagline: "Never wonder what to serve alongside dinner again — get drink pairings suggested for any dish.",
bullets: [
"Wine, beer, cocktail, and non-alcoholic suggestions",
"Tailored to the specific dish, not generic pairing charts",
"Explains why each pairing works",
],
},
meal_pairing: {
tagline: "Turn a single recipe into a full meal — get side dish, salad, and sauce suggestions that actually complement it.",
bullets: [
"Starters, sides, salads, breads, and sauces suggested per dish",
"One click to generate and save any suggestion as its own recipe",
"Pick multiple pairings and generate them all at once",
],
},
recipe_import_url: {
tagline: "Stop retyping recipes from other sites — paste a link and get a fully structured recipe in seconds.",
bullets: [
"Works with most recipe blogs and sites",
"Extracts ingredients, steps, servings, and timing automatically",
"Edit anything before saving",
],
},
recipe_import_photo: {
tagline: "Digitize a cookbook page or handwritten family recipe just by photographing it.",
bullets: [
"Recognizes printed or handwritten recipes",
"Extracts ingredients and steps automatically",
"Saved as a private, editable recipe",
],
},
nutrition_estimation: {
tagline: "Know what's in every dish — get calorie and macro estimates for any recipe, no manual entry required.",
bullets: [
"Calories, protein, carbs, fat, fiber, and sodium per serving",
"Combines AI estimation with USDA reference data",
"Powers your weekly nutrition rollup on the meal plan",
],
},
markdown_export: {
tagline: "Take your recipes, meal plans, and shopping lists anywhere — export clean Markdown you can paste into any note app.",
bullets: [
"Works on recipes, collections, meal plans, and pantry lists",
"Copy to clipboard or download as a .md file",
"Clean formatting, no app lock-in",
],
},
weekly_nutrition: {
tagline: "See how your whole week stacks up against your nutrition goals, not just one meal at a time.",
bullets: [
"Daily average calories and macros across your meal plan week",
"Compared directly against your saved nutrition goals",
"Flags days with missing nutrition data",
],
},
version_history: {
tagline: "Never lose a good version of a recipe — every edit is saved, so you can compare or roll back anytime.",
bullets: [
"Every save keeps a full snapshot of the prior version",
"Side-by-side diff view to see exactly what changed",
"Restore any past version with one click",
],
},
grocery_delivery: {
tagline: "Skip the manual shopping trip — send your list straight to a grocery delivery provider.",
bullets: [
"One click from your shopping list to checkout",
"Keeps quantities and aisle grouping intact",
"No re-typing your list into another app",
],
},
};
export function UpgradeDialog({
open,
onOpenChange,
@@ -24,18 +111,33 @@ export function UpgradeDialog({
featureKey: string;
featureLabel: string;
}) {
const copy = FEATURE_COPY[featureKey];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
A Pro feature
{featureLabel}
</DialogTitle>
<DialogDescription>
{featureLabel} is available on the Pro plan (4.99/mo). Free accounts don&apos;t include it.
{copy?.tagline ?? `${featureLabel} is available on the Pro plan. Free accounts don't include it.`}
</DialogDescription>
</DialogHeader>
{copy && (
<ul className="space-y-1.5">
{copy.bullets.map((bullet) => (
<li key={bullet} className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<span>{bullet}</span>
</li>
))}
</ul>
)}
<p className="text-sm text-muted-foreground">
Included on the Pro plan (4.99/mo).
</p>
<DialogFooter className="sm:justify-start">
<Link
href={`/support?upgrade=${encodeURIComponent(featureKey)}`}
@@ -21,6 +21,8 @@ import { cn } from "@/lib/utils";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { useLocale } from "@/lib/i18n/provider";
import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields";
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
import { ProBadge } from "@/components/premium/pro-badge";
const SURPRISE_PROMPTS = [
"A cozy one-pot dish with whatever is in my pantry on a rainy evening",
@@ -57,9 +59,18 @@ type GeneratedRecipe = {
export function AiGenerateDialog({
open,
onOpenChange,
photoTabAvailable = true,
photoTabLocked = false,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Tier feature flag (recipe_import_photo) disabled for every tier —
* hides the Photo tab entirely, since there's no upgrade path. */
photoTabAvailable?: boolean;
/** Available on some tier but not the viewer's — the tab still shows
* (with a "Pro" upsell); generating opens an upgrade prompt instead of
* calling the import API. */
photoTabLocked?: boolean;
}) {
const t = useTranslations("ai.generate");
const tCommon = useTranslations("common");
@@ -90,6 +101,7 @@ export function AiGenerateDialog({
});
const [busy, setBusy] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
function handleClose() {
if (busy) return;
@@ -150,6 +162,10 @@ export function AiGenerateDialog({
async function handlePhotoGenerate() {
if (!photoBase64) return;
if (photoTabLocked) {
setUpgradeOpen(true);
return;
}
setBusy(true);
try {
const res = await fetch("/api/v1/ai/import-photo", {
@@ -206,6 +222,7 @@ export function AiGenerateDialog({
const canSubmit = tab === "describe" ? !!prompt.trim() : tab === "photo" ? !!photoBase64 : batchFields.dinners + batchFields.lunches > 0;
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-xl max-h-[85vh] flex flex-col gap-0 p-0">
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
@@ -223,7 +240,7 @@ export function AiGenerateDialog({
<div className="flex gap-1 p-1 bg-muted rounded-lg">
{([
{ key: "describe", label: t("tabDescribe"), icon: Type },
{ key: "photo", label: t("tabPhoto"), icon: Camera },
...(photoTabAvailable ? [{ key: "photo" as Tab, label: t("tabPhoto"), icon: Camera }] : []),
{ key: "batch", label: tBatch("title"), icon: ChefHat },
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
<button
@@ -231,7 +248,7 @@ export function AiGenerateDialog({
onClick={() => setTab(key)}
disabled={busy}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-all",
"flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-sm font-medium transition-all",
tab === key
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -239,6 +256,7 @@ export function AiGenerateDialog({
>
<Icon className="h-4 w-4" />
{label}
{key === "photo" && photoTabLocked && <ProBadge />}
</button>
))}
</div>
@@ -370,5 +388,12 @@ export function AiGenerateDialog({
</div>
</DialogContent>
</Dialog>
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="recipe_import_photo"
featureLabel="Import from photo"
/>
</>
);
}
+11 -1
View File
@@ -92,6 +92,8 @@ export function RecipesHeader({
sharedUrl,
importUrlAvailable = true,
importUrlLocked = false,
importPhotoAvailable = true,
importPhotoLocked = false,
}: {
count: number;
initialQuery?: string;
@@ -114,6 +116,9 @@ export function RecipesHeader({
* (with a "Pro" upsell) and opens an upgrade prompt instead of the
* import dialog. */
importUrlLocked?: boolean;
/** Same idea, for the AI-generate dialog's Photo tab (recipe_import_photo). */
importPhotoAvailable?: boolean;
importPhotoLocked?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -340,7 +345,12 @@ export function RecipesHeader({
</div>
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<AiGenerateDialog
open={aiOpen}
onOpenChange={setAiOpen}
photoTabAvailable={importPhotoAvailable}
photoTabLocked={importPhotoLocked}
/>
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl && !importUrlLocked} />
<UpgradeDialog
open={upgradeOpen}
@@ -21,6 +21,8 @@ import {
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { VersionDiffView, type DiffSnapshot } from "@/components/recipe/version-diff-view";
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
import { ProBadge } from "@/components/premium/pro-badge";
type VersionSummary = {
id: string;
@@ -49,9 +51,14 @@ type VersionDetail = {
export function VersionHistoryButton({
recipeId,
currentSnapshot,
locked = false,
}: {
recipeId: string;
currentSnapshot: DiffSnapshot;
/** Available on some tier but not the viewer's — button still shows
* (with a "Pro" upsell) and opens an upgrade prompt instead of the
* version sheet. */
locked?: boolean;
}) {
const t = useTranslations("recipe");
const tForm = useTranslations("recipeForm");
@@ -68,8 +75,13 @@ export function VersionHistoryButton({
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
const [restoringId, setRestoringId] = useState<string | null>(null);
const [comparingId, setComparingId] = useState<string | null>(null);
const [upgradeOpen, setUpgradeOpen] = useState(false);
async function handleOpen(isOpen: boolean) {
if (isOpen && locked) {
setUpgradeOpen(true);
return;
}
setOpen(isOpen);
if (isOpen) {
setLoading(true);
@@ -137,7 +149,10 @@ export function VersionHistoryButton({
<History className="h-4 w-4" />
</Button>
} />
<TooltipContent>{t("historyTooltip")}</TooltipContent>
<TooltipContent className="flex items-center gap-1.5">
{t("historyTooltip")}
{locked && <ProBadge />}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet open={open} onOpenChange={handleOpen}>
@@ -247,6 +262,12 @@ export function VersionHistoryButton({
)}
</DialogContent>
</Dialog>
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="version_history"
featureLabel="Recipe version history"
/>
</>
);
}