@@ -63,10 +67,12 @@ export default async function ShoppingListPage({ params }: Params) {
} />
{m.common.print}
-
+ {canExportMarkdown && (
+
+ )}
{access.role === "owner" && (
)}
diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts
index 7d4f6e8..e4b0677 100644
--- a/apps/web/app/api/v1/ai/import-photo/route.ts
+++ b/apps/web/app/api/v1/ai/import-photo/route.ts
@@ -7,6 +7,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
const Schema = z.object({
imageBase64: z.string().max(14_000_000),
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "recipe_import_photo");
+ if (featureDenied) return featureDenied;
+
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts
index 4775467..0d5b7ca 100644
--- a/apps/web/app/api/v1/ai/import-url/route.ts
+++ b/apps/web/app/api/v1/ai/import-url/route.ts
@@ -6,6 +6,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { importFromUrl } from "@/lib/ai/features/import-url";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { withUserKey } from "@/lib/ai/resolve-user-key";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
const Schema = z.object({
url: z.string().url(),
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "recipe_import_url");
+ if (featureDenied) return featureDenied;
+
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts
index 99e03f9..c6c668a 100644
--- a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, mealPlanEntries, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ weekStart: string }> };
@@ -16,6 +17,9 @@ export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "weekly_nutrition");
+ if (featureDenied) return featureDenied;
+
const { weekStart } = await params;
const userId = session!.user.id;
diff --git a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts
index ebe0978..e9eed79 100644
--- a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts
+++ b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts
@@ -5,6 +5,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota } from "@/lib/ai/ai-error";
import { estimateNutrition } from "@/lib/ai/features/estimate-nutrition";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
@@ -33,6 +34,9 @@ export async function POST(req: NextRequest, { params }: Params) {
if (response) return response;
const { id } = await params;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "nutrition_estimation");
+ if (featureDenied) return featureDenied;
+
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
diff --git a/apps/web/app/api/v1/recipes/bulk/export/route.ts b/apps/web/app/api/v1/recipes/bulk/export/route.ts
index 3cf9ae1..a944c22 100644
--- a/apps/web/app/api/v1/recipes/bulk/export/route.ts
+++ b/apps/web/app/api/v1/recipes/bulk/export/route.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { db, recipes, eq, and, inArray } from "@epicure/db";
import { recipeToMarkdown } from "@/lib/markdown/recipe";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
const ExportSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
@@ -12,6 +13,9 @@ export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "markdown_export");
+ if (featureDenied) return featureDenied;
+
const body = ExportSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts
index 278cee9..9cbdf1f 100644
--- a/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts
+++ b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts
@@ -4,6 +4,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { getShoppingListAccess } from "@/lib/shopping-list-access";
import { buildGroceryExportPayload } from "@/lib/grocery-export";
import { createInstacartShoppingListLink } from "@/lib/grocery-providers/instacart";
+import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
@@ -12,6 +13,9 @@ export async function POST(req: NextRequest, { params }: Params) {
if (response) return response;
const { id } = await params;
+ const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "grocery_delivery");
+ if (featureDenied) return featureDenied;
+
const access = await getShoppingListAccess(id, session!.user.id);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
diff --git a/apps/web/components/admin/feature-flags-form.tsx b/apps/web/components/admin/feature-flags-form.tsx
index 74b289d..22c7d56 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 keep its button visible but gated — clicking it shows an upgrade prompt instead of running.
+ Disable a feature for a tier to hide it for that tier's users (most features hide entirely; a few show a locked/upgrade state instead — see each feature's actual behavior in the app).
diff --git a/apps/web/components/marketing/language-switcher.tsx b/apps/web/components/marketing/language-switcher.tsx
index e973ed1..7b020c6 100644
--- a/apps/web/components/marketing/language-switcher.tsx
+++ b/apps/web/components/marketing/language-switcher.tsx
@@ -10,7 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { MARKETING_LOCALE_COOKIE } from "@/lib/marketing-locale-cookie";
import type { Locale } from "@/lib/i18n/provider";
-import { FlagGB, FlagFR } from "./flag-icons";
+import { FlagGB, FlagFR } from "@/components/shared/flag-icons";
const OPTIONS: { code: Locale; Flag: typeof FlagGB; label: string }[] = [
{ code: "en", Flag: FlagGB, label: "English" },
diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx
index f3cb7fd..a211c90 100644
--- a/apps/web/components/pantry/pantry-page-header.tsx
+++ b/apps/web/components/pantry/pantry-page-header.tsx
@@ -9,7 +9,7 @@ import { pantryToMarkdown } from "@/lib/markdown/pantry";
type PantryItem = { rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null };
-export function PantryPageHeader({ items }: { items: PantryItem[] }) {
+export function PantryPageHeader({ items, canExportMarkdown = true }: { items: PantryItem[]; canExportMarkdown?: boolean }) {
const t = useTranslations("pantry");
const tCommon = useTranslations("common");
return (
@@ -27,7 +27,7 @@ export function PantryPageHeader({ items }: { items: PantryItem[] }) {
{tCommon("print")}
-
+ {canExportMarkdown &&
}
);
diff --git a/apps/web/components/recipe/nutrition-panel.tsx b/apps/web/components/recipe/nutrition-panel.tsx
index fbcc97b..aaadb8b 100644
--- a/apps/web/components/recipe/nutrition-panel.tsx
+++ b/apps/web/components/recipe/nutrition-panel.tsx
@@ -20,9 +20,13 @@ 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;
}
-export function NutritionPanel({ recipeId, initialData, initialManual }: NutritionPanelProps) {
+export function NutritionPanel({ recipeId, initialData, initialManual, estimateEnabled = true }: NutritionPanelProps) {
const t = useTranslations("nutritionPanel");
const [nutrition, setNutrition] = useState