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 (
+ <>
+ setUpgradeOpen(true)}
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
+ >
+ {t("dailyAverageVsGoals")}
+
+
+
+ >
+ );
+ }
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
+
- {locked && }
} />
- {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}
}
-
+
{t("estimateButton")}
+ {estimateLocked && }
+
);
}
@@ -70,16 +87,19 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
{t("title")}
- {estimateEnabled && (
-
- {loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
-
+ {estimateAvailable && (
+ <>
+
+ {loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
+ {estimateLocked && }
+
+ >
)}
{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 && (
- setUrlOpen(true)}>
+ {importUrlAvailable && (
+ (importUrlLocked ? setUpgradeOpen(true) : setUrlOpen(true))}
+ >
{t("importUrl")}
+ {importUrlLocked && }
)}
@@ -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",