feat: 6 new per-tier feature flags (off by default) + flag-icon locale switcher + hide BYOK when disabled (v0.74.0)
Extends the existing feature-flags system (previously 3 keys, all default-enabled) with 6 more: recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery. Each FEATURE_DEFINITIONS entry now carries its own defaultEnabled -- the new 6 default to false, the original 3 stay true -- so no migration/seed was needed for the "off by default" requirement, just a per-key fallback instead of a blanket true. Gated server-side (requireFeatureEnabledResponse, a new shared helper avoiding six copies of the same try/catch) on: import-url, import-photo, nutrition POST estimate, bulk markdown export, weekly meal-plan nutrition GET, Instacart export. Gated client-side by hiding the trigger entirely (not just disabling) on every page that renders one: recipe detail (meal/drink pairing buttons, nutrition panel's estimate button, markdown export), recipes list (import-URL button, including the OS Share Target auto-import path), new-recipe page (photo import), meal-plan page (markdown export, weekly nutrition bar), shopping-list/collection/pantry pages (markdown export), shopping-list page (Instacart button, now gated by both the existing env-var check AND the tier flag). Also: BYOK section on Settings -> AI now hidden entirely for non-BYOK users (previously showed a locked-and-teased notice, same inconsistency the Model Prefs fix closed yesterday). Language switcher shows a flag icon (FlagGB/FlagFR, moved from components/marketing to components/shared so both the logged-in settings switcher and the logged-out marketing one can use it) instead of plain "English"/"Français" text-only options. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db, featureFlags, users, eq, and } from "@epicure/db";
|
||||
|
||||
export type Tier = "free" | "pro" | "family";
|
||||
@@ -8,19 +9,64 @@ export const FEATURE_DEFINITIONS = [
|
||||
key: "recipe_variations",
|
||||
label: "Recipe variations",
|
||||
description: "AI-generated variations of a recipe (dietary swaps, flavor twists, etc.).",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
key: "drink_pairing",
|
||||
label: "Drink pairing",
|
||||
description: "AI-suggested drink pairings for a recipe.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
key: "meal_pairing",
|
||||
label: "Meal pairing",
|
||||
description: "AI-suggested side dish / meal pairings for a recipe.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
// The following ship disabled by default (2026-07-24) — turn on per tier
|
||||
// from this page once ready, rather than a code change.
|
||||
{
|
||||
key: "recipe_import_url",
|
||||
label: "Import from URL",
|
||||
description: "Import a recipe by pasting a link to another site.",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
key: "recipe_import_photo",
|
||||
label: "Import from photo",
|
||||
description: "Import a recipe by photographing a cookbook page or handwritten card.",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
key: "nutrition_estimation",
|
||||
label: "Nutrition estimation",
|
||||
description: "AI/USDA-estimated nutrition facts for a recipe.",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
key: "markdown_export",
|
||||
label: "Markdown export",
|
||||
description: "Export a recipe, collection, meal plan, or pantry list as Markdown.",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
key: "weekly_nutrition",
|
||||
label: "Weekly nutrition",
|
||||
description: "Nutrition rollup across a whole meal-plan week.",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
key: "grocery_delivery",
|
||||
label: "Grocery delivery integration",
|
||||
description: "Export a shopping list to a grocery delivery provider (e.g. Instacart).",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ENABLED: Record<string, boolean> = Object.fromEntries(
|
||||
FEATURE_DEFINITIONS.map((f) => [f.key, f.defaultEnabled])
|
||||
);
|
||||
|
||||
export type FeatureKey = (typeof FEATURE_DEFINITIONS)[number]["key"];
|
||||
export const FEATURE_KEYS = FEATURE_DEFINITIONS.map((f) => f.key) as FeatureKey[];
|
||||
|
||||
@@ -41,7 +87,7 @@ export async function getFeatureFlagMatrix(): Promise<Record<FeatureKey, Record<
|
||||
for (const key of FEATURE_KEYS) {
|
||||
matrix[key] = {} as Record<Tier, boolean>;
|
||||
for (const tier of TIERS) {
|
||||
matrix[key][tier] = overrides.get(`${key}:${tier}`) ?? true;
|
||||
matrix[key][tier] = overrides.get(`${key}:${tier}`) ?? DEFAULT_ENABLED[key] ?? true;
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
@@ -67,7 +113,7 @@ export async function isFeatureEnabledForTier(featureKey: FeatureKey, tier: Tier
|
||||
.select({ enabled: featureFlags.enabled })
|
||||
.from(featureFlags)
|
||||
.where(and(eq(featureFlags.featureKey, featureKey), eq(featureFlags.tier, tier)));
|
||||
return row ? row.enabled : true;
|
||||
return row ? row.enabled : (DEFAULT_ENABLED[featureKey] ?? true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,3 +127,21 @@ export async function requireFeatureEnabled(userId: string, featureKey: FeatureK
|
||||
const enabled = await isFeatureEnabledForTier(featureKey, tier);
|
||||
if (!enabled) throw new FeatureDisabledError(featureKey);
|
||||
}
|
||||
|
||||
/** Route-level convenience: returns the 403 response to return early, or
|
||||
* null if the feature is enabled — avoids repeating the same try/catch
|
||||
* around requireFeatureEnabled in every gated route. */
|
||||
export async function requireFeatureEnabledResponse(userId: string, featureKey: FeatureKey): Promise<NextResponse | null> {
|
||||
try {
|
||||
await requireFeatureEnabled(userId, featureKey);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof FeatureDisabledError) {
|
||||
return NextResponse.json(
|
||||
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user