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
+9
View File
@@ -2,6 +2,15 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.80.0 — 2026-07-24 16:45
### Added
- Recipe version history is now a 10th per-tier feature flag (enabled by default, same as variations/pairings), with the same locked-vs-hidden treatment as the others.
- Upgrade prompts for locked features now show real detail — a tagline and 2-3 concrete bullet points per feature — instead of one generic sentence for every feature.
### Fixed
- The AI-generate dialog's Photo tab was a second, completely unguarded entry point into recipe_import_photo — reachable and fully functional regardless of the feature being disabled for your tier, even though the dedicated Photo Import button was correctly gated. Now hidden/locked in step with every other entry point (server-side enforcement in the API route was already correct; this was a UI-only gap).
## 0.79.0 — 2026-07-24 16:00 ## 0.79.0 — 2026-07-24 16:00
### Fixed ### Fixed
+3 -2
View File
@@ -116,8 +116,9 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` | | Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` |
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}``past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` | | Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}``past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) | | Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 9 capabilities by tier: recipe_variations/drink_pairing/meal_pairing (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` | | Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 10 capabilities by tier: recipe_variations/drink_pairing/meal_pairing/version_history (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 9 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/pro-badge.tsx` | | Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 10 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, recipe version history, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). `UpgradeDialog` itself now shows a per-feature tagline + bullet list (`FEATURE_COPY` in the dialog component) instead of one generic sentence for every feature. | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/{pro-badge,upgrade-dialog}.tsx` |
| AI-generate dialog's Photo tab bypassed the photo-import gate (closed 2026-07-24) | Exists | A second, unguarded front door into `recipe_import_photo` — the tab in `AiGenerateDialog` had no tier check at all, while the dedicated `PhotoImportButton` on `/recipes/new` did. Server-side `requireFeatureEnabledResponse` on the API route meant a fully-disabled feature still 403'd, but a viewer without the tier could reach the tab, fill it in, and hit a dead-end error instead of an upgrade prompt. Now the tab hides/locks in step with `PhotoImportButton`, threaded from `RecipesHeader`. | `apps/web/components/recipe/ai-generate-dialog.tsx`, `apps/web/components/recipe/recipes-header.tsx` |
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) | | **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` | | User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` | | Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
+5
View File
@@ -117,6 +117,7 @@ export default async function RecipePage({ params }: Params) {
mealPairing: !featureFlags.meal_pairing[viewerTier], mealPairing: !featureFlags.meal_pairing[viewerTier],
nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier], nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier],
markdownExport: !featureFlags.markdown_export[viewerTier], markdownExport: !featureFlags.markdown_export[viewerTier],
versionHistory: !featureFlags.version_history[viewerTier],
}; };
// A feature disabled for every tier has no upgrade path, so it hides // A feature disabled for every tier has no upgrade path, so it hides
// outright; one enabled for at least one tier still shows (locked, with a // outright; one enabled for at least one tier still shows (locked, with a
@@ -127,6 +128,7 @@ export default async function RecipePage({ params }: Params) {
mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"), mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"),
nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"), nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"),
markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"), markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"),
versionHistory: isFeatureAvailableAnyTier(featureFlags, "version_history"),
}; };
const isOwner = recipe.authorId === session.user.id; const isOwner = recipe.authorId === session.user.id;
@@ -289,6 +291,7 @@ export default async function RecipePage({ params }: Params) {
)} )}
{isOwner && ( {isOwner && (
<> <>
{available.versionHistory && (
<VersionHistoryButton <VersionHistoryButton
recipeId={id} recipeId={id}
currentSnapshot={{ currentSnapshot={{
@@ -305,7 +308,9 @@ export default async function RecipePage({ params }: Params) {
timerSeconds: s.timerSeconds, timerSeconds: s.timerSeconds,
})), })),
}} }}
locked={locked.versionHistory}
/> />
)}
<Tooltip> <Tooltip>
<TooltipTrigger render={ <TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}> <Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
+4
View File
@@ -64,6 +64,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
const featureFlags = await getFeatureFlagMatrix(); const featureFlags = await getFeatureFlagMatrix();
const importUrlLocked = !featureFlags.recipe_import_url[viewerTier]; const importUrlLocked = !featureFlags.recipe_import_url[viewerTier];
const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url"); const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url");
const importPhotoLocked = !featureFlags.recipe_import_photo[viewerTier];
const importPhotoAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_photo");
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams; const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
const sharedUrl = extractSharedUrl({ url, text }); const sharedUrl = extractSharedUrl({ url, text });
@@ -166,6 +168,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined} sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined}
importUrlAvailable={importUrlAvailable} importUrlAvailable={importUrlAvailable}
importUrlLocked={importUrlLocked} importUrlLocked={importUrlLocked}
importPhotoAvailable={importPhotoAvailable}
importPhotoLocked={importPhotoLocked}
/> />
<RecipesEmptyState query={query} count={total} /> <RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} /> <RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
@@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db"; import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max, desc } from "@epicure/db"; import { eq, and, max, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string; versionId: string }> }; type Params = { params: Promise<{ id: string; versionId: string }> };
export async function GET(req: NextRequest, { params }: Params) { export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response; if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id, versionId } = await params; const { id, versionId } = await params;
// Verify ownership // Verify ownership
@@ -31,6 +36,10 @@ export async function GET(req: NextRequest, { params }: Params) {
export async function POST(req: NextRequest, { params }: Params) { export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response; if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id, versionId } = await params; const { id, versionId } = await params;
const body = await req.json() as { action?: string }; const body = await req.json() as { action?: string };
@@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeSnapshots } from "@epicure/db"; import { db, recipes, recipeSnapshots } from "@epicure/db";
import { eq, and, desc } from "@epicure/db"; import { eq, and, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) { export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response; if (response) return response;
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history");
if (featureDenied) return featureDenied;
const { id } = await params; const { id } = await params;
// Verify ownership // Verify ownership
+105 -3
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Sparkles } from "lucide-react"; import { Sparkles, Check } from "lucide-react";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -13,6 +13,93 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } 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({ export function UpgradeDialog({
open, open,
onOpenChange, onOpenChange,
@@ -24,18 +111,33 @@ export function UpgradeDialog({
featureKey: string; featureKey: string;
featureLabel: string; featureLabel: string;
}) { }) {
const copy = FEATURE_COPY[featureKey];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
A Pro feature {featureLabel}
</DialogTitle> </DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </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"> <DialogFooter className="sm:justify-start">
<Link <Link
href={`/support?upgrade=${encodeURIComponent(featureKey)}`} href={`/support?upgrade=${encodeURIComponent(featureKey)}`}
@@ -21,6 +21,8 @@ import { cn } from "@/lib/utils";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { useLocale } from "@/lib/i18n/provider"; import { useLocale } from "@/lib/i18n/provider";
import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields"; 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 = [ const SURPRISE_PROMPTS = [
"A cozy one-pot dish with whatever is in my pantry on a rainy evening", "A cozy one-pot dish with whatever is in my pantry on a rainy evening",
@@ -57,9 +59,18 @@ type GeneratedRecipe = {
export function AiGenerateDialog({ export function AiGenerateDialog({
open, open,
onOpenChange, onOpenChange,
photoTabAvailable = true,
photoTabLocked = false,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; 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 t = useTranslations("ai.generate");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -90,6 +101,7 @@ export function AiGenerateDialog({
}); });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
function handleClose() { function handleClose() {
if (busy) return; if (busy) return;
@@ -150,6 +162,10 @@ export function AiGenerateDialog({
async function handlePhotoGenerate() { async function handlePhotoGenerate() {
if (!photoBase64) return; if (!photoBase64) return;
if (photoTabLocked) {
setUpgradeOpen(true);
return;
}
setBusy(true); setBusy(true);
try { try {
const res = await fetch("/api/v1/ai/import-photo", { 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; const canSubmit = tab === "describe" ? !!prompt.trim() : tab === "photo" ? !!photoBase64 : batchFields.dinners + batchFields.lunches > 0;
return ( return (
<>
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-xl max-h-[85vh] flex flex-col gap-0 p-0"> <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"> <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"> <div className="flex gap-1 p-1 bg-muted rounded-lg">
{([ {([
{ key: "describe", label: t("tabDescribe"), icon: Type }, { 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 }, { key: "batch", label: tBatch("title"), icon: ChefHat },
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => ( ] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
<button <button
@@ -231,7 +248,7 @@ export function AiGenerateDialog({
onClick={() => setTab(key)} onClick={() => setTab(key)}
disabled={busy} disabled={busy}
className={cn( 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 tab === key
? "bg-background shadow-sm text-foreground" ? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
@@ -239,6 +256,7 @@ export function AiGenerateDialog({
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{label} {label}
{key === "photo" && photoTabLocked && <ProBadge />}
</button> </button>
))} ))}
</div> </div>
@@ -370,5 +388,12 @@ export function AiGenerateDialog({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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, sharedUrl,
importUrlAvailable = true, importUrlAvailable = true,
importUrlLocked = false, importUrlLocked = false,
importPhotoAvailable = true,
importPhotoLocked = false,
}: { }: {
count: number; count: number;
initialQuery?: string; initialQuery?: string;
@@ -114,6 +116,9 @@ export function RecipesHeader({
* (with a "Pro" upsell) and opens an upgrade prompt instead of the * (with a "Pro" upsell) and opens an upgrade prompt instead of the
* import dialog. */ * import dialog. */
importUrlLocked?: boolean; importUrlLocked?: boolean;
/** Same idea, for the AI-generate dialog's Photo tab (recipe_import_photo). */
importPhotoAvailable?: boolean;
importPhotoLocked?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -340,7 +345,12 @@ export function RecipesHeader({
</div> </div>
</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} /> <UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl && !importUrlLocked} />
<UpgradeDialog <UpgradeDialog
open={upgradeOpen} open={upgradeOpen}
@@ -21,6 +21,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { VersionDiffView, type DiffSnapshot } from "@/components/recipe/version-diff-view"; 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 = { type VersionSummary = {
id: string; id: string;
@@ -49,9 +51,14 @@ type VersionDetail = {
export function VersionHistoryButton({ export function VersionHistoryButton({
recipeId, recipeId,
currentSnapshot, currentSnapshot,
locked = false,
}: { }: {
recipeId: string; recipeId: string;
currentSnapshot: DiffSnapshot; 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 t = useTranslations("recipe");
const tForm = useTranslations("recipeForm"); const tForm = useTranslations("recipeForm");
@@ -68,8 +75,13 @@ export function VersionHistoryButton({
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({}); const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
const [restoringId, setRestoringId] = useState<string | null>(null); const [restoringId, setRestoringId] = useState<string | null>(null);
const [comparingId, setComparingId] = useState<string | null>(null); const [comparingId, setComparingId] = useState<string | null>(null);
const [upgradeOpen, setUpgradeOpen] = useState(false);
async function handleOpen(isOpen: boolean) { async function handleOpen(isOpen: boolean) {
if (isOpen && locked) {
setUpgradeOpen(true);
return;
}
setOpen(isOpen); setOpen(isOpen);
if (isOpen) { if (isOpen) {
setLoading(true); setLoading(true);
@@ -137,7 +149,10 @@ export function VersionHistoryButton({
<History className="h-4 w-4" /> <History className="h-4 w-4" />
</Button> </Button>
} /> } />
<TooltipContent>{t("historyTooltip")}</TooltipContent> <TooltipContent className="flex items-center gap-1.5">
{t("historyTooltip")}
{locked && <ProBadge />}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<Sheet open={open} onOpenChange={handleOpen}> <Sheet open={open} onOpenChange={handleOpen}>
@@ -247,6 +262,12 @@ export function VersionHistoryButton({
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<UpgradeDialog
open={upgradeOpen}
onOpenChange={setUpgradeOpen}
featureKey="version_history"
featureLabel="Recipe version history"
/>
</> </>
); );
} }
+12 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.79.0"; export const APP_VERSION = "0.80.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,17 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.80.0",
date: "2026-07-24 16:45",
added: [
"Recipe version history is now a 10th per-tier feature flag (enabled by default, same as variations/pairings), with the same locked-vs-hidden treatment as the others.",
"Upgrade prompts for locked features now show real detail — a tagline and 2-3 concrete bullet points per feature — instead of one generic sentence for every feature.",
],
fixed: [
"The AI-generate dialog's Photo tab was a second, completely unguarded entry point into recipe_import_photo — reachable and fully functional regardless of the feature being disabled for your tier, even though the dedicated Photo Import button was correctly gated. Now hidden/locked in step with every other entry point (server-side enforcement in the API route was already correct; this was a UI-only gap).",
],
},
{ {
version: "0.79.0", version: "0.79.0",
date: "2026-07-24 16:00", date: "2026-07-24 16:00",
+6
View File
@@ -23,6 +23,12 @@ export const FEATURE_DEFINITIONS = [
description: "AI-suggested side dish / meal pairings for a recipe.", description: "AI-suggested side dish / meal pairings for a recipe.",
defaultEnabled: true, defaultEnabled: true,
}, },
{
key: "version_history",
label: "Recipe version history",
description: "Snapshots of a recipe's prior versions, with diff/compare and restore.",
defaultEnabled: true,
},
// The following ship disabled by default (2026-07-24) — turn on per tier // The following ship disabled by default (2026-07-24) — turn on per tier
// from this page once ready, rather than a code change. // from this page once ready, rather than a code change.
{ {
+3 -3
View File
@@ -296,9 +296,9 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.79.0", "version": "0.80.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.79.0", "version": "0.80.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",