From 1dd8abfd522fb1a5092f9ab31b59786088553dbe Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 24 Jul 2026 13:33:04 +0200 Subject: [PATCH] fix: standardize locked-vs-hidden treatment across all 9 per-tier gated features (v0.79.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 ++ FEATURE_AUDIT.md | 4 +- apps/web/app/(app)/collections/[id]/page.tsx | 9 ++- apps/web/app/(app)/meal-plan/page.tsx | 13 ++-- apps/web/app/(app)/pantry/page.tsx | 8 ++- apps/web/app/(app)/recipes/[id]/page.tsx | 69 ++++++++++++------- apps/web/app/(app)/recipes/new/page.tsx | 7 +- apps/web/app/(app)/recipes/page.tsx | 10 +-- .../app/(app)/shopping-lists/[id]/page.tsx | 14 ++-- .../components/admin/feature-flags-form.tsx | 2 +- .../nutrition/weekly-nutrition-bar.tsx | 32 ++++++++- .../components/pantry/pantry-page-header.tsx | 14 +++- apps/web/components/premium/pro-badge.tsx | 12 ++++ .../recipe/drink-pairing-button.tsx | 11 +-- .../components/recipe/meal-pairing-button.tsx | 10 +-- .../web/components/recipe/nutrition-panel.tsx | 60 +++++++++++----- .../components/recipe/photo-import-button.tsx | 16 ++++- apps/web/components/recipe/recipes-header.tsx | 36 +++++++--- .../components/recipe/variations-button.tsx | 4 +- .../shared/export-markdown-button.tsx | 32 +++++++++ .../shopping-lists/grocery-export-button.tsx | 25 +++++-- apps/web/lib/changelog.ts | 9 ++- apps/web/lib/feature-flags.ts | 9 +++ apps/web/package.json | 2 +- package.json | 2 +- 25 files changed, 316 insertions(+), 99 deletions(-) create mode 100644 apps/web/components/premium/pro-badge.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36dae6b..9b6f3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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.79.0 — 2026-07-24 16:00 + +### Fixed +- Standardized locked-feature treatment across every per-tier gated feature: if a feature is enabled on at least one tier, it stays visible with a "Pro" badge (clicking opens an upgrade prompt) instead of hiding; only a feature disabled on every tier hides outright. Applies to recipe variations, meal/drink pairings, nutrition estimation, Markdown export (recipe, meal plan, shopping list, collection, pantry), weekly nutrition, import from URL, import from photo, and the Instacart grocery-delivery option. + ## 0.78.3 — 2026-07-24 15:00 ### Fixed diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 731112a..950dbb8 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -116,8 +116,8 @@ 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 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`) | -| Variations button locked treatment (revised 2026-07-24) | Exists | Only gated feature that stays visible-but-locked instead of hidden (deliberate, per explicit feedback: hiding it was tried first, then reverted). The lock-icon-overlapping-the-branch-icon look was removed; button renders normally, click opens `UpgradeDialog`, and the tooltip shows a small "Pro" badge instead of an icon overlay. | `apps/web/components/recipe/variations-button.tsx`, `apps/web/app/(app)/recipes/[id]/page.tsx` | -| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags now gate 9 capabilities by tier (was 3): recipe_variations/drink_pairing/meal_pairing (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (2026-07-24, 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 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` | +| 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` | | **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` | | Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` | diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 8302120..c3ce396 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -18,7 +18,7 @@ import { buttonVariants } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; import { EmptyState } from "@/components/shared/empty-state"; import { collectionToMarkdown } from "@/lib/markdown/collection"; import { getMessages } from "@/lib/i18n/server"; @@ -33,7 +33,9 @@ export default async function CollectionPage({ params }: Params) { if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; - const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier]; + const featureFlags = await getFeatureFlagMatrix(); + const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; + const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); const col = await db.query.collections.findFirst({ where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)), @@ -85,13 +87,14 @@ export default async function CollectionPage({ params }: Params) { } /> {m.collections.exportPdf} - {canExportMarkdown && } )} diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 83e3508..3bb8af4 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -12,7 +12,7 @@ import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list- import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan"; import { getMessages, formatMessage } from "@/lib/i18n/server"; @@ -62,8 +62,10 @@ export default async function MealPlanPage({ const msgs = getMessages((session.user as { locale?: string }).locale); const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; const featureFlags = await getFeatureFlagMatrix(); - const canExportMarkdown = featureFlags.markdown_export[viewerTier]; - const canSeeWeeklyNutrition = featureFlags.weekly_nutrition[viewerTier]; + const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; + const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); + const weeklyNutritionLocked = !featureFlags.weekly_nutrition[viewerTier]; + const weeklyNutritionAvailable = isFeatureAvailableAnyTier(featureFlags, "weekly_nutrition"); const monday = getMonday(week); const weekStart = toDateStr(monday); @@ -162,10 +164,11 @@ export default async function MealPlanPage({ } /> {msgs.common.print} - {canExportMarkdown && ( + {markdownExportAvailable && ( )} @@ -180,7 +183,7 @@ export default async function MealPlanPage({ - {canSeeWeeklyNutrition && } + {weeklyNutritionAvailable && } {sharedMemberships.length > 0 && ( diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 7720688..04de42a 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -10,7 +10,7 @@ import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers"; import { scoreRecipesAgainstPantry } from "@/lib/pantry-match"; import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match"; import { getPublicUrl } from "@/lib/storage"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; @@ -18,7 +18,9 @@ export default async function PantryPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; - const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier]; + const featureFlags = await getFeatureFlagMatrix(); + const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; + const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); const [items, candidateRecipes, cookedDishes] = await Promise.all([ db.query.pantryItems.findMany({ @@ -78,7 +80,7 @@ export default async function PantryPage() { return (
- + i.id).join(",")} initialItems={mappedItems} /> diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index fdafe05..eaf7fee 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -44,7 +44,7 @@ import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { recipeToMarkdown } from "@/lib/markdown/recipe"; import { getMessages, formatMessage } from "@/lib/i18n/server"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; @@ -118,6 +118,16 @@ export default async function RecipePage({ params }: Params) { nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier], markdownExport: !featureFlags.markdown_export[viewerTier], }; + // 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 + // "Pro" upsell) even when the viewer's own tier lacks it. + const available = { + variations: isFeatureAvailableAnyTier(featureFlags, "recipe_variations"), + drinkPairing: isFeatureAvailableAnyTier(featureFlags, "drink_pairing"), + mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"), + nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"), + markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"), + }; const isOwner = recipe.authorId === session.user.id; @@ -197,8 +207,8 @@ export default async function RecipePage({ params }: Params) { {!recipe.isBatchCook && recipe.recipeType !== "drink" && ( <> - {!locked.mealPairing && } - {!locked.drinkPairing && } + {available.mealPairing && } + {available.drinkPairing && } )} {recipe.visibility === "public" && ( @@ -232,31 +242,33 @@ export default async function RecipePage({ params }: Params) { ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))} /> )} - ({ - rawName: ing.rawName, - quantity: ing.quantity, - unit: ing.unit, - note: ing.note, - order: ing.order, - }))} - steps={recipe.steps.map((s) => ({ - instruction: s.instruction, - timerSeconds: s.timerSeconds, - order: s.order, - }))} - locked={locked.variations} - /> + {available.variations && ( + ({ + rawName: ing.rawName, + quantity: ing.quantity, + unit: ing.unit, + note: ing.note, + order: ing.order, + }))} + steps={recipe.steps.map((s) => ({ + instruction: s.instruction, + timerSeconds: s.timerSeconds, + order: s.order, + }))} + locked={locked.variations} + /> + )} - {!locked.markdownExport && ( + {available.markdownExport && ( )} {isOwner && ( @@ -430,7 +443,13 @@ export default async function RecipePage({ params }: Params) { order: ing.order, }))} /> - +
)} diff --git a/apps/web/app/(app)/recipes/new/page.tsx b/apps/web/app/(app)/recipes/new/page.tsx index 24289fa..4ab5187 100644 --- a/apps/web/app/(app)/recipes/new/page.tsx +++ b/apps/web/app/(app)/recipes/new/page.tsx @@ -4,7 +4,7 @@ import { auth } from "@/lib/auth/server"; import { RecipeForm } from "@/components/recipe/recipe-form"; import { NewRecipeHeader } from "@/components/recipe/new-recipe-header"; import { PhotoImportButton } from "@/components/recipe/photo-import-button"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; @@ -12,13 +12,14 @@ export default async function NewRecipePage() { const session = await auth.api.getSession({ headers: await headers() }); const viewerTier = (session?.user as { tier?: string } | undefined)?.tier as Tier | undefined ?? "free"; const featureFlags = await getFeatureFlagMatrix(); - const canImportPhoto = featureFlags.recipe_import_photo[viewerTier]; + const importPhotoLocked = !featureFlags.recipe_import_photo[viewerTier]; + const importPhotoAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_photo"); return (
- {canImportPhoto && } + {importPhotoAvailable && }
diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index a9ba815..d8f6fa2 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -10,7 +10,7 @@ import { RecipesGrid } from "@/components/recipe/recipes-grid"; import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel"; import { getMessages } from "@/lib/i18n/server"; import { getFeaturePrefs } from "@/lib/feature-prefs"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; @@ -62,7 +62,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const featurePrefs = await getFeaturePrefs(session.user.id); const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; const featureFlags = await getFeatureFlagMatrix(); - const canImportUrl = featureFlags.recipe_import_url[viewerTier]; + const importUrlLocked = !featureFlags.recipe_import_url[viewerTier]; + const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url"); const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams; const sharedUrl = extractSharedUrl({ url, text }); @@ -162,8 +163,9 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear initialTag={tagFilter ?? ""} initialBatchCook={batchCookFilter ?? ""} initialRecipeType={recipeTypeFilter ?? ""} - sharedUrl={canImportUrl ? sharedUrl : undefined} - showImportUrl={canImportUrl} + sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined} + importUrlAvailable={importUrlAvailable} + importUrlLocked={importUrlLocked} /> diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index d84d279..2d47d29 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -16,7 +16,7 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { shoppingListToMarkdown } from "@/lib/markdown/shopping-list"; import { getMessages, formatMessage } from "@/lib/i18n/server"; -import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; +import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; @@ -40,8 +40,11 @@ export default async function ShoppingListPage({ params }: Params) { const canEdit = canWriteShoppingList(access.role); const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; const featureFlags = await getFeatureFlagMatrix(); - const canExportMarkdown = featureFlags.markdown_export[viewerTier]; - const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart" && featureFlags.grocery_delivery[viewerTier]; + const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; + const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); + const instacartProviderConfigured = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart"; + const instacartLocked = !featureFlags.grocery_delivery[viewerTier]; + const instacartAvailable = instacartProviderConfigured && isFeatureAvailableAnyTier(featureFlags, "grocery_delivery"); return (
@@ -55,7 +58,7 @@ export default async function ShoppingListPage({ params }: Params) {
- + {access.role === "owner" && ( )} @@ -67,10 +70,11 @@ export default async function ShoppingListPage({ params }: Params) { } /> {m.common.print} - {canExportMarkdown && ( + {markdownExportAvailable && ( )} {access.role === "owner" && ( diff --git a/apps/web/components/admin/feature-flags-form.tsx b/apps/web/components/admin/feature-flags-form.tsx index c02084c..771c7cb 100644 --- a/apps/web/components/admin/feature-flags-form.tsx +++ b/apps/web/components/admin/feature-flags-form.tsx @@ -48,7 +48,7 @@ export function FeatureFlagsForm({

Feature Toggles

- Disable a feature for a tier to hide it for that tier's users (most features hide entirely; recipe variations instead shows a "Pro" badge in its tooltip and opens an upgrade prompt — see each feature's actual behavior in the app). + Disable a feature for a tier to gate it for that tier's users. If the feature is still enabled on at least one other tier, it stays visible with a "Pro" upsell (clicking opens an upgrade prompt) — it only disappears entirely once every tier has it off, since at that point there's no upgrade path to point at.

diff --git a/apps/web/components/nutrition/weekly-nutrition-bar.tsx b/apps/web/components/nutrition/weekly-nutrition-bar.tsx index 9ed824b..5d4a3e6 100644 --- a/apps/web/components/nutrition/weekly-nutrition-bar.tsx +++ b/apps/web/components/nutrition/weekly-nutrition-bar.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; +import { UpgradeDialog } from "@/components/premium/upgrade-dialog"; +import { ProBadge } from "@/components/premium/pro-badge"; type NutritionTotals = { calories: number; @@ -34,6 +36,9 @@ type NutritionResponse = { interface WeeklyNutritionBarProps { weekStart: string; + /** Available on some tier but not the viewer's — shows a locked teaser + * (with a "Pro" upsell) instead of hiding, and skips fetching totals. */ + locked?: boolean; } type BarItem = { @@ -45,16 +50,39 @@ type BarItem = { colorClass: string; }; -export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) { +export function WeeklyNutritionBar({ weekStart, locked = false }: WeeklyNutritionBarProps) { const t = useTranslations("mealPlan.nutritionBar"); const [data, setData] = useState(null); + const [upgradeOpen, setUpgradeOpen] = useState(false); useEffect(() => { + if (locked) return; fetch(`/api/v1/meal-plans/${weekStart}/nutrition`) .then((res) => (res.ok ? res.json() : null)) .then((json) => setData(json)) .catch(() => setData(null)); - }, [weekStart]); + }, [weekStart, locked]); + + if (locked) { + return ( + <> + + + + ); + } if (!data || !data.goals) return null; diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx index a211c90..bc96b5c 100644 --- a/apps/web/components/pantry/pantry-page-header.tsx +++ b/apps/web/components/pantry/pantry-page-header.tsx @@ -9,7 +9,15 @@ import { pantryToMarkdown } from "@/lib/markdown/pantry"; type PantryItem = { rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }; -export function PantryPageHeader({ items, canExportMarkdown = true }: { items: PantryItem[]; canExportMarkdown?: boolean }) { +export function PantryPageHeader({ + items, + markdownExportAvailable = true, + markdownExportLocked = false, +}: { + items: PantryItem[]; + markdownExportAvailable?: boolean; + markdownExportLocked?: boolean; +}) { const t = useTranslations("pantry"); const tCommon = useTranslations("common"); return ( @@ -27,7 +35,9 @@ export function PantryPageHeader({ items, canExportMarkdown = true }: { items: P {tCommon("print")} - {canExportMarkdown && } + {markdownExportAvailable && ( + + )}
); diff --git a/apps/web/components/premium/pro-badge.tsx b/apps/web/components/premium/pro-badge.tsx new file mode 100644 index 0000000..9317a76 --- /dev/null +++ b/apps/web/components/premium/pro-badge.tsx @@ -0,0 +1,12 @@ +import { Badge } from "@/components/ui/badge"; + +/** Small "Pro" indicator for a feature that's locked for the viewer's tier + * but unlockable by upgrading (as opposed to a feature disabled for every + * tier, which hides instead of showing this). */ +export function ProBadge({ className }: { className?: string }) { + return ( + + Pro + + ); +} diff --git a/apps/web/components/recipe/drink-pairing-button.tsx b/apps/web/components/recipe/drink-pairing-button.tsx index 32ecb3a..364a27c 100644 --- a/apps/web/components/recipe/drink-pairing-button.tsx +++ b/apps/web/components/recipe/drink-pairing-button.tsx @@ -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 + } /> - {t("drinksTooltip")} + + {t("drinksTooltip")} + {locked && } + diff --git a/apps/web/components/recipe/meal-pairing-button.tsx b/apps/web/components/recipe/meal-pairing-button.tsx index 5273a48..51c33da 100644 --- a/apps/web/components/recipe/meal-pairing-button.tsx +++ b/apps/web/components/recipe/meal-pairing-button.tsx @@ -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" > - {locked && } } /> - {t("pairMealTooltip")} + + {t("pairMealTooltip")} + {locked && } +
diff --git a/apps/web/components/recipe/nutrition-panel.tsx b/apps/web/components/recipe/nutrition-panel.tsx index aaadb8b..e21b88a 100644 --- a/apps/web/components/recipe/nutrition-panel.tsx +++ b/apps/web/components/recipe/nutrition-panel.tsx @@ -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(initialData ?? null); const [manual, setManual] = useState(!!initialManual); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 (
{error &&

{error}

} - +
); } @@ -70,16 +87,19 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
{t("title")} - {estimateEnabled && ( - + {estimateAvailable && ( + <> + + )}
{manual && !loading && ( @@ -132,6 +152,12 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE

)} + ); } diff --git a/apps/web/components/recipe/photo-import-button.tsx b/apps/web/components/recipe/photo-import-button.tsx index bd99059..a86cf3e 100644 --- a/apps/web/components/recipe/photo-import-button.tsx +++ b/apps/web/components/recipe/photo-import-button.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [stage, setStage] = useState<"recognizing" | "generating">("recognizing"); const stageTimerRef = useRef | null>(null); + const [upgradeOpen, setUpgradeOpen] = useState(false); function handleClick() { + if (locked) { + setUpgradeOpen(true); + return; + } fileRef.current?.click(); } @@ -88,12 +95,19 @@ export function PhotoImportButton() { )} {t("importFromPhoto")} + {locked && } + ); } diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index de52dd3..2e25b29 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -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({ {t("generate")} - {showImportUrl && ( - )} @@ -327,7 +341,13 @@ export function RecipesHeader({ - + + ); } diff --git a/apps/web/components/recipe/variations-button.tsx b/apps/web/components/recipe/variations-button.tsx index b510b47..2fbd38c 100644 --- a/apps/web/components/recipe/variations-button.tsx +++ b/apps/web/components/recipe/variations-button.tsx @@ -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({ } /> {t("variationsTooltip")} - {locked && Pro} + {locked && } diff --git a/apps/web/components/shared/export-markdown-button.tsx b/apps/web/components/shared/export-markdown-button.tsx index e5f936f..609421e 100644 --- a/apps/web/components/shared/export-markdown-button.tsx +++ b/apps/web/components/shared/export-markdown-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Copy, Download, FileDown } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; @@ -11,15 +12,46 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UpgradeDialog } from "@/components/premium/upgrade-dialog"; +import { ProBadge } from "@/components/premium/pro-badge"; export function ExportMarkdownButton({ markdown, filename, + locked = false, }: { markdown: string; filename: string; + locked?: boolean; }) { const t = useTranslations("common"); + const [upgradeOpen, setUpgradeOpen] = useState(false); + + if (locked) { + return ( + <> + + + setUpgradeOpen(true)}> + + + } /> + + {t("exportMarkdown")} + + + + + + + ); + } async function handleCopy() { try { diff --git a/apps/web/components/shopping-lists/grocery-export-button.tsx b/apps/web/components/shopping-lists/grocery-export-button.tsx index c486cd1..fb60d63 100644 --- a/apps/web/components/shopping-lists/grocery-export-button.tsx +++ b/apps/web/components/shopping-lists/grocery-export-button.tsx @@ -14,16 +14,24 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { GroceryExportPayload } from "@/lib/grocery-export"; import { groceryExportToText } from "@/lib/grocery-export"; +import { UpgradeDialog } from "@/components/premium/upgrade-dialog"; +import { ProBadge } from "@/components/premium/pro-badge"; interface Props { listId: string; - /** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart — otherwise only "copy as text" is offered. */ - instacartEnabled: boolean; + /** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart and the feature is + * enabled for at least one tier — otherwise only "copy as text" is + * offered, since there's no upgrade path to point at. */ + instacartAvailable: boolean; + /** Available on some tier but not the viewer's — the menu item still + * shows (with a "Pro" upsell) instead of hiding. */ + instacartLocked?: boolean; } -export function GroceryExportButton({ listId, instacartEnabled }: Props) { +export function GroceryExportButton({ listId, instacartAvailable, instacartLocked = false }: Props) { const t = useTranslations("shoppingLists"); const [loading, setLoading] = useState(false); + const [upgradeOpen, setUpgradeOpen] = useState(false); async function fetchPayload(): Promise { const res = await fetch(`/api/v1/shopping-lists/${listId}/export`); @@ -80,13 +88,20 @@ export function GroceryExportButton({ listId, instacartEnabled }: Props) { {t("copyAsText")} - {instacartEnabled && ( - void handleInstacart()}> + {instacartAvailable && ( + (instacartLocked ? setUpgradeOpen(true) : void handleInstacart())}> {t("sendToInstacart")} + {instacartLocked && } )} + ); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index e956d71..9717203 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.78.3"; +export const APP_VERSION = "0.79.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.79.0", + date: "2026-07-24 16:00", + fixed: [ + "Standardized locked-feature treatment across every per-tier gated feature: if a feature is enabled on at least one tier, it stays visible with a \"Pro\" badge (clicking opens an upgrade prompt) instead of hiding; only a feature disabled on every tier hides outright. Applies to recipe variations, meal/drink pairings, nutrition estimation, Markdown export (recipe, meal plan, shopping list, collection, pantry), weekly nutrition, import from URL, import from photo, and the Instacart grocery-delivery option.", + ], + }, { version: "0.78.3", date: "2026-07-24 15:00", diff --git a/apps/web/lib/feature-flags.ts b/apps/web/lib/feature-flags.ts index bf74333..76e792d 100644 --- a/apps/web/lib/feature-flags.ts +++ b/apps/web/lib/feature-flags.ts @@ -93,6 +93,15 @@ export async function getFeatureFlagMatrix(): Promise>, key: FeatureKey): boolean { + return TIERS.some((tier) => matrix[key][tier]); +} + export async function setFeatureFlag( featureKey: FeatureKey, tier: Tier, diff --git a/apps/web/package.json b/apps/web/package.json index 63c7d6d..1b53fbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.78.3", + "version": "0.79.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index aa0e10a..1267658 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.78.3", + "version": "0.79.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",