fix: standardize locked-vs-hidden treatment across all 9 per-tier gated features (v0.79.0)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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
|
## 0.78.3 — 2026-07-24 15:00
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+2
-2
@@ -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 webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` |
|
||||||
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
|
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
|
||||||
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
|
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
|
||||||
| 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 gate 9 capabilities by tier: recipe_variations/drink_pairing/meal_pairing (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
|
||||||
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags 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` |
|
| 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`) |
|
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
|
||||||
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
|
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
|
||||||
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
|
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { buttonVariants } from "@/components/ui/button";
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
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 { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { collectionToMarkdown } from "@/lib/markdown/collection";
|
import { collectionToMarkdown } from "@/lib/markdown/collection";
|
||||||
import { getMessages } from "@/lib/i18n/server";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
@@ -33,7 +33,9 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const m = getMessages((session.user as { locale?: string }).locale);
|
const m = getMessages((session.user as { locale?: string }).locale);
|
||||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
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({
|
const col = await db.query.collections.findFirst({
|
||||||
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
|
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
|
||||||
@@ -85,13 +87,14 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
} />
|
} />
|
||||||
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
|
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canExportMarkdown && <ExportMarkdownButton
|
{markdownExportAvailable && <ExportMarkdownButton
|
||||||
markdown={collectionToMarkdown({
|
markdown={collectionToMarkdown({
|
||||||
name: col.name,
|
name: col.name,
|
||||||
description: col.description,
|
description: col.description,
|
||||||
recipes: recipeList,
|
recipes: recipeList,
|
||||||
})}
|
})}
|
||||||
filename={col.name}
|
filename={col.name}
|
||||||
|
locked={markdownExportLocked}
|
||||||
/>}
|
/>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-
|
|||||||
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
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 { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
|
||||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
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 msgs = getMessages((session.user as { locale?: string }).locale);
|
||||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
||||||
const featureFlags = await getFeatureFlagMatrix();
|
const featureFlags = await getFeatureFlagMatrix();
|
||||||
const canExportMarkdown = featureFlags.markdown_export[viewerTier];
|
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
|
||||||
const canSeeWeeklyNutrition = featureFlags.weekly_nutrition[viewerTier];
|
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
|
||||||
|
const weeklyNutritionLocked = !featureFlags.weekly_nutrition[viewerTier];
|
||||||
|
const weeklyNutritionAvailable = isFeatureAvailableAnyTier(featureFlags, "weekly_nutrition");
|
||||||
|
|
||||||
const monday = getMonday(week);
|
const monday = getMonday(week);
|
||||||
const weekStart = toDateStr(monday);
|
const weekStart = toDateStr(monday);
|
||||||
@@ -162,10 +164,11 @@ export default async function MealPlanPage({
|
|||||||
} />
|
} />
|
||||||
<TooltipContent>{msgs.common.print}</TooltipContent>
|
<TooltipContent>{msgs.common.print}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canExportMarkdown && (
|
{markdownExportAvailable && (
|
||||||
<ExportMarkdownButton
|
<ExportMarkdownButton
|
||||||
markdown={mealPlanToMarkdown({ label, entries })}
|
markdown={mealPlanToMarkdown({ label, entries })}
|
||||||
filename={`meal-plan-${weekStart}`}
|
filename={`meal-plan-${weekStart}`}
|
||||||
|
locked={markdownExportLocked}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -180,7 +183,7 @@ export default async function MealPlanPage({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{canSeeWeeklyNutrition && <WeeklyNutritionBar weekStart={weekStart} />}
|
{weeklyNutritionAvailable && <WeeklyNutritionBar weekStart={weekStart} locked={weeklyNutritionLocked} />}
|
||||||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
|
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
|
||||||
|
|
||||||
{sharedMemberships.length > 0 && (
|
{sharedMemberships.length > 0 && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
|
|||||||
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
||||||
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
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 = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ export default async function PantryPage() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
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([
|
const [items, candidateRecipes, cookedDishes] = await Promise.all([
|
||||||
db.query.pantryItems.findMany({
|
db.query.pantryItems.findMany({
|
||||||
@@ -78,7 +80,7 @@ export default async function PantryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PantryPageHeader items={mappedItems} canExportMarkdown={canExportMarkdown} />
|
<PantryPageHeader items={mappedItems} markdownExportAvailable={markdownExportAvailable} markdownExportLocked={markdownExportLocked} />
|
||||||
<ExpiringLeftovers leftovers={leftovers} />
|
<ExpiringLeftovers leftovers={leftovers} />
|
||||||
<ExpiringSoonSuggestions suggestions={suggestions} />
|
<ExpiringSoonSuggestions suggestions={suggestions} />
|
||||||
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
|
|||||||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
||||||
import { recipeToMarkdown } from "@/lib/markdown/recipe";
|
import { recipeToMarkdown } from "@/lib/markdown/recipe";
|
||||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
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 }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -118,6 +118,16 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier],
|
nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier],
|
||||||
markdownExport: !featureFlags.markdown_export[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;
|
const isOwner = recipe.authorId === session.user.id;
|
||||||
|
|
||||||
@@ -197,8 +207,8 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||||
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
||||||
<>
|
<>
|
||||||
{!locked.mealPairing && <MealPairingButton recipeId={id} locked={false} />}
|
{available.mealPairing && <MealPairingButton recipeId={id} locked={locked.mealPairing} />}
|
||||||
{!locked.drinkPairing && <DrinkPairingButton recipeId={id} locked={false} />}
|
{available.drinkPairing && <DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{recipe.visibility === "public" && (
|
{recipe.visibility === "public" && (
|
||||||
@@ -232,31 +242,33 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))}
|
ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<VariationsButton
|
{available.variations && (
|
||||||
recipeId={id}
|
<VariationsButton
|
||||||
baseServings={recipe.baseServings}
|
recipeId={id}
|
||||||
difficulty={recipe.difficulty}
|
baseServings={recipe.baseServings}
|
||||||
prepMins={recipe.prepMins}
|
difficulty={recipe.difficulty}
|
||||||
cookMins={recipe.cookMins}
|
prepMins={recipe.prepMins}
|
||||||
ingredients={recipe.ingredients.map((ing) => ({
|
cookMins={recipe.cookMins}
|
||||||
rawName: ing.rawName,
|
ingredients={recipe.ingredients.map((ing) => ({
|
||||||
quantity: ing.quantity,
|
rawName: ing.rawName,
|
||||||
unit: ing.unit,
|
quantity: ing.quantity,
|
||||||
note: ing.note,
|
unit: ing.unit,
|
||||||
order: ing.order,
|
note: ing.note,
|
||||||
}))}
|
order: ing.order,
|
||||||
steps={recipe.steps.map((s) => ({
|
}))}
|
||||||
instruction: s.instruction,
|
steps={recipe.steps.map((s) => ({
|
||||||
timerSeconds: s.timerSeconds,
|
instruction: s.instruction,
|
||||||
order: s.order,
|
timerSeconds: s.timerSeconds,
|
||||||
}))}
|
order: s.order,
|
||||||
locked={locked.variations}
|
}))}
|
||||||
/>
|
locked={locked.variations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
|
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
|
||||||
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
||||||
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
|
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
|
||||||
<PrintButton recipeId={id} />
|
<PrintButton recipeId={id} />
|
||||||
{!locked.markdownExport && (
|
{available.markdownExport && (
|
||||||
<ExportMarkdownButton
|
<ExportMarkdownButton
|
||||||
markdown={recipeToMarkdown({
|
markdown={recipeToMarkdown({
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -272,6 +284,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
batchDishes: recipe.batchDishes,
|
batchDishes: recipe.batchDishes,
|
||||||
})}
|
})}
|
||||||
filename={recipe.title}
|
filename={recipe.title}
|
||||||
|
locked={locked.markdownExport}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
@@ -430,7 +443,13 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
order: ing.order,
|
order: ing.order,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} estimateEnabled={!locked.nutritionEstimation} />
|
<NutritionPanel
|
||||||
|
recipeId={id}
|
||||||
|
initialData={recipe.nutritionData}
|
||||||
|
initialManual={recipe.nutritionManual}
|
||||||
|
estimateAvailable={available.nutritionEstimation}
|
||||||
|
estimateLocked={locked.nutritionEstimation}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { auth } from "@/lib/auth/server";
|
|||||||
import { RecipeForm } from "@/components/recipe/recipe-form";
|
import { RecipeForm } from "@/components/recipe/recipe-form";
|
||||||
import { NewRecipeHeader } from "@/components/recipe/new-recipe-header";
|
import { NewRecipeHeader } from "@/components/recipe/new-recipe-header";
|
||||||
import { PhotoImportButton } from "@/components/recipe/photo-import-button";
|
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 = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -12,13 +12,14 @@ export default async function NewRecipePage() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
const viewerTier = (session?.user as { tier?: string } | undefined)?.tier as Tier | undefined ?? "free";
|
const viewerTier = (session?.user as { tier?: string } | undefined)?.tier as Tier | undefined ?? "free";
|
||||||
const featureFlags = await getFeatureFlagMatrix();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<NewRecipeHeader />
|
<NewRecipeHeader />
|
||||||
{canImportPhoto && <PhotoImportButton />}
|
{importPhotoAvailable && <PhotoImportButton locked={importPhotoLocked} />}
|
||||||
</div>
|
</div>
|
||||||
<RecipeForm />
|
<RecipeForm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
|||||||
import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel";
|
import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel";
|
||||||
import { getMessages } from "@/lib/i18n/server";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
import { getFeaturePrefs } from "@/lib/feature-prefs";
|
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 = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -62,7 +62,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
const featurePrefs = await getFeaturePrefs(session.user.id);
|
const featurePrefs = await getFeaturePrefs(session.user.id);
|
||||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
||||||
const featureFlags = await getFeatureFlagMatrix();
|
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 { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
|
||||||
const sharedUrl = extractSharedUrl({ url, text });
|
const sharedUrl = extractSharedUrl({ url, text });
|
||||||
@@ -162,8 +163,9 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
initialTag={tagFilter ?? ""}
|
initialTag={tagFilter ?? ""}
|
||||||
initialBatchCook={batchCookFilter ?? ""}
|
initialBatchCook={batchCookFilter ?? ""}
|
||||||
initialRecipeType={recipeTypeFilter ?? ""}
|
initialRecipeType={recipeTypeFilter ?? ""}
|
||||||
sharedUrl={canImportUrl ? sharedUrl : undefined}
|
sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined}
|
||||||
showImportUrl={canImportUrl}
|
importUrlAvailable={importUrlAvailable}
|
||||||
|
importUrlLocked={importUrlLocked}
|
||||||
/>
|
/>
|
||||||
<RecipesEmptyState query={query} count={total} />
|
<RecipesEmptyState query={query} count={total} />
|
||||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
|
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list
|
|||||||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
||||||
import { shoppingListToMarkdown } from "@/lib/markdown/shopping-list";
|
import { shoppingListToMarkdown } from "@/lib/markdown/shopping-list";
|
||||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
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 }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -40,8 +40,11 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
const canEdit = canWriteShoppingList(access.role);
|
const canEdit = canWriteShoppingList(access.role);
|
||||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
||||||
const featureFlags = await getFeatureFlagMatrix();
|
const featureFlags = await getFeatureFlagMatrix();
|
||||||
const canExportMarkdown = featureFlags.markdown_export[viewerTier];
|
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
|
||||||
const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart" && featureFlags.grocery_delivery[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 (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
@@ -55,7 +58,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
|
<GroceryExportButton listId={id} instacartAvailable={instacartAvailable} instacartLocked={instacartLocked} />
|
||||||
{access.role === "owner" && (
|
{access.role === "owner" && (
|
||||||
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
|
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
|
||||||
)}
|
)}
|
||||||
@@ -67,10 +70,11 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
} />
|
} />
|
||||||
<TooltipContent>{m.common.print}</TooltipContent>
|
<TooltipContent>{m.common.print}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canExportMarkdown && (
|
{markdownExportAvailable && (
|
||||||
<ExportMarkdownButton
|
<ExportMarkdownButton
|
||||||
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
|
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
|
||||||
filename={list.name}
|
filename={list.name}
|
||||||
|
locked={markdownExportLocked}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{access.role === "owner" && (
|
{access.role === "owner" && (
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function FeatureFlagsForm({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-lg">Feature Toggles</h2>
|
<h2 className="font-semibold text-lg">Feature Toggles</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||||
|
import { ProBadge } from "@/components/premium/pro-badge";
|
||||||
|
|
||||||
type NutritionTotals = {
|
type NutritionTotals = {
|
||||||
calories: number;
|
calories: number;
|
||||||
@@ -34,6 +36,9 @@ type NutritionResponse = {
|
|||||||
|
|
||||||
interface WeeklyNutritionBarProps {
|
interface WeeklyNutritionBarProps {
|
||||||
weekStart: string;
|
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 = {
|
type BarItem = {
|
||||||
@@ -45,16 +50,39 @@ type BarItem = {
|
|||||||
colorClass: string;
|
colorClass: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
export function WeeklyNutritionBar({ weekStart, locked = false }: WeeklyNutritionBarProps) {
|
||||||
const t = useTranslations("mealPlan.nutritionBar");
|
const t = useTranslations("mealPlan.nutritionBar");
|
||||||
const [data, setData] = useState<NutritionResponse | null>(null);
|
const [data, setData] = useState<NutritionResponse | null>(null);
|
||||||
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (locked) return;
|
||||||
fetch(`/api/v1/meal-plans/${weekStart}/nutrition`)
|
fetch(`/api/v1/meal-plans/${weekStart}/nutrition`)
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((json) => setData(json))
|
.then((json) => setData(json))
|
||||||
.catch(() => setData(null));
|
.catch(() => setData(null));
|
||||||
}, [weekStart]);
|
}, [weekStart, locked]);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUpgradeOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("dailyAverageVsGoals")}
|
||||||
|
<ProBadge />
|
||||||
|
</button>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="weekly_nutrition"
|
||||||
|
featureLabel="Weekly nutrition"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data || !data.goals) return null;
|
if (!data || !data.goals) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { pantryToMarkdown } from "@/lib/markdown/pantry";
|
|||||||
|
|
||||||
type PantryItem = { rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null };
|
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 t = useTranslations("pantry");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +35,9 @@ export function PantryPageHeader({ items, canExportMarkdown = true }: { items: P
|
|||||||
<Printer className="h-4 w-4" />
|
<Printer className="h-4 w-4" />
|
||||||
{tCommon("print")}
|
{tCommon("print")}
|
||||||
</a>
|
</a>
|
||||||
{canExportMarkdown && <ExportMarkdownButton markdown={pantryToMarkdown({ items })} filename="pantry" />}
|
{markdownExportAvailable && (
|
||||||
|
<ExportMarkdownButton markdown={pantryToMarkdown({ items })} filename="pantry" locked={markdownExportLocked} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Badge variant="secondary" className={`text-[10px] px-1 py-0 leading-4 ${className ?? ""}`}>
|
||||||
|
Pro
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||||
|
import { ProBadge } from "@/components/premium/pro-badge";
|
||||||
|
|
||||||
type Drink = {
|
type Drink = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,12 +88,14 @@ export function DrinkPairingButton({ recipeId, locked = false }: { recipeId: str
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={
|
<TooltipTrigger render={
|
||||||
<Button variant="ghost" size="icon" onClick={handleOpen} aria-label={t("drinksTooltip")} className="relative">
|
<Button variant="ghost" size="icon" onClick={handleOpen} aria-label={t("drinksTooltip")}>
|
||||||
<Wine className="h-4 w-4" />
|
<Wine className="h-4 w-4" />
|
||||||
{locked && <Lock className="h-2.5 w-2.5 absolute bottom-1 right-1 text-muted-foreground" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<TooltipContent>{t("drinksTooltip")}</TooltipContent>
|
<TooltipContent className="flex items-center gap-1.5">
|
||||||
|
{t("drinksTooltip")}
|
||||||
|
{locked && <ProBadge />}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
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 { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||||
|
import { ProBadge } from "@/components/premium/pro-badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
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();
|
if (pairings.length === 0) suggest();
|
||||||
}}
|
}}
|
||||||
aria-label={t("pairMealTooltip")}
|
aria-label={t("pairMealTooltip")}
|
||||||
className="relative"
|
|
||||||
>
|
>
|
||||||
<UtensilsCrossed className="h-4 w-4" />
|
<UtensilsCrossed className="h-4 w-4" />
|
||||||
{locked && <Lock className="h-2.5 w-2.5 absolute bottom-1 right-1 text-muted-foreground" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<TooltipContent>{t("pairMealTooltip")}</TooltipContent>
|
<TooltipContent className="flex items-center gap-1.5">
|
||||||
|
{t("pairMealTooltip")}
|
||||||
|
{locked && <ProBadge />}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||||
|
import { ProBadge } from "@/components/premium/pro-badge";
|
||||||
|
|
||||||
type NutritionData = {
|
type NutritionData = {
|
||||||
perServing: {
|
perServing: {
|
||||||
@@ -20,20 +22,28 @@ interface NutritionPanelProps {
|
|||||||
recipeId: string;
|
recipeId: string;
|
||||||
initialData?: NutritionData | null;
|
initialData?: NutritionData | null;
|
||||||
initialManual?: boolean;
|
initialManual?: boolean;
|
||||||
/** AI/USDA estimation feature toggled off for this tier — hides the
|
/** AI/USDA estimation disabled for every tier — hides the (re-)estimate
|
||||||
* (re-)estimate action. Previously-stored data (manual or a past
|
* action entirely. Previously-stored data (manual or a past estimate)
|
||||||
* estimate) still displays; there's just no button to refresh it. */
|
* still displays; there's just no button to refresh it. */
|
||||||
estimateEnabled?: boolean;
|
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 t = useTranslations("nutritionPanel");
|
||||||
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
|
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
|
||||||
const [manual, setManual] = useState(!!initialManual);
|
const [manual, setManual] = useState(!!initialManual);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
|
|
||||||
async function handleEstimate() {
|
async function handleEstimate() {
|
||||||
|
if (estimateLocked) {
|
||||||
|
setUpgradeOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -54,13 +64,20 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!nutrition && !loading) {
|
if (!nutrition && !loading) {
|
||||||
if (!estimateEnabled) return null;
|
if (!estimateAvailable) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<Button variant="outline" onClick={handleEstimate} disabled={loading}>
|
<Button variant="outline" onClick={handleEstimate} disabled={loading} className="self-start gap-1.5">
|
||||||
{t("estimateButton")}
|
{t("estimateButton")}
|
||||||
|
{estimateLocked && <ProBadge />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="nutrition_estimation"
|
||||||
|
featureLabel="Nutrition estimation"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,16 +87,19 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">{t("title")}</CardTitle>
|
<CardTitle className="text-base">{t("title")}</CardTitle>
|
||||||
{estimateEnabled && (
|
{estimateAvailable && (
|
||||||
<Button
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleEstimate}
|
size="sm"
|
||||||
disabled={loading}
|
onClick={handleEstimate}
|
||||||
className="text-xs text-muted-foreground"
|
disabled={loading}
|
||||||
>
|
className="text-xs text-muted-foreground gap-1.5"
|
||||||
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
|
>
|
||||||
</Button>
|
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
|
||||||
|
{estimateLocked && <ProBadge />}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{manual && !loading && (
|
{manual && !loading && (
|
||||||
@@ -132,6 +152,12 @@ export function NutritionPanel({ recipeId, initialData, initialManual, estimateE
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="nutrition_estimation"
|
||||||
|
featureLabel="Nutrition estimation"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ import { Camera, Loader2 } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
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 t = useTranslations("recipe");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [stage, setStage] = useState<"recognizing" | "generating">("recognizing");
|
const [stage, setStage] = useState<"recognizing" | "generating">("recognizing");
|
||||||
const stageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const stageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
if (locked) {
|
||||||
|
setUpgradeOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
fileRef.current?.click();
|
fileRef.current?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +95,19 @@ export function PhotoImportButton() {
|
|||||||
<Camera className="mr-2 h-4 w-4" />
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{t("importFromPhoto")}
|
{t("importFromPhoto")}
|
||||||
|
{locked && <ProBadge />}
|
||||||
</Button>
|
</Button>
|
||||||
<FakeProgressBar
|
<FakeProgressBar
|
||||||
active={loading}
|
active={loading}
|
||||||
durationMs={12000}
|
durationMs={12000}
|
||||||
label={loading ? (stage === "recognizing" ? t("recognizingPhoto") : t("writingRecipe")) : undefined}
|
label={loading ? (stage === "recognizing" ? t("recognizingPhoto") : t("writingRecipe")) : undefined}
|
||||||
/>
|
/>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="recipe_import_photo"
|
||||||
|
featureLabel="Import from photo"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AiGenerateDialog } from "./ai-generate-dialog";
|
import { AiGenerateDialog } from "./ai-generate-dialog";
|
||||||
import { UrlImportDialog } from "./url-import-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 }) {
|
function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useState(value);
|
||||||
@@ -88,7 +90,8 @@ export function RecipesHeader({
|
|||||||
initialBatchCook = "",
|
initialBatchCook = "",
|
||||||
initialRecipeType = "",
|
initialRecipeType = "",
|
||||||
sharedUrl,
|
sharedUrl,
|
||||||
showImportUrl = true,
|
importUrlAvailable = true,
|
||||||
|
importUrlLocked = false,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
@@ -104,16 +107,21 @@ export function RecipesHeader({
|
|||||||
* Auto-opens the import dialog pre-filled instead of requiring the user
|
* Auto-opens the import dialog pre-filled instead of requiring the user
|
||||||
* to paste the link again. */
|
* to paste the link again. */
|
||||||
sharedUrl?: string;
|
sharedUrl?: string;
|
||||||
/** Tier feature flag (recipe_import_url) — hides the button and dialog
|
/** Tier feature flag (recipe_import_url) disabled for every tier — hides
|
||||||
* entirely when off, not just disabled. */
|
* the button and dialog entirely, since there's no upgrade path. */
|
||||||
showImportUrl?: boolean;
|
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 router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = useTranslations("recipes");
|
const t = useTranslations("recipes");
|
||||||
const tRecipe = useTranslations("recipe");
|
const tRecipe = useTranslations("recipe");
|
||||||
const [aiOpen, setAiOpen] = useState(false);
|
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 [query, setQuery] = useState(initialQuery);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -164,10 +172,16 @@ export function RecipesHeader({
|
|||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
{t("generate")}
|
{t("generate")}
|
||||||
</Button>
|
</Button>
|
||||||
{showImportUrl && (
|
{importUrlAvailable && (
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setUrlOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => (importUrlLocked ? setUpgradeOpen(true) : setUrlOpen(true))}
|
||||||
|
>
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
{t("importUrl")}
|
{t("importUrl")}
|
||||||
|
{importUrlLocked && <ProBadge />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
|
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
|
||||||
@@ -327,7 +341,13 @@ export function RecipesHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
|
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
|
||||||
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl} />
|
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} initialUrl={sharedUrl} autoImport={!!sharedUrl && !importUrlLocked} />
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="recipe_import_url"
|
||||||
|
featureLabel="Import from URL"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { VariationsDialog } from "./variations-dialog";
|
import { VariationsDialog } from "./variations-dialog";
|
||||||
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
import { UpgradeDialog } from "@/components/premium/upgrade-dialog";
|
||||||
|
import { ProBadge } from "@/components/premium/pro-badge";
|
||||||
|
|
||||||
export function VariationsButton({
|
export function VariationsButton({
|
||||||
recipeId,
|
recipeId,
|
||||||
@@ -48,7 +48,7 @@ export function VariationsButton({
|
|||||||
} />
|
} />
|
||||||
<TooltipContent className="flex items-center gap-1.5">
|
<TooltipContent className="flex items-center gap-1.5">
|
||||||
{t("variationsTooltip")}
|
{t("variationsTooltip")}
|
||||||
{locked && <Badge variant="secondary" className="text-[10px] px-1 py-0 leading-4">Pro</Badge>}
|
{locked && <ProBadge />}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Copy, Download, FileDown } from "lucide-react";
|
import { Copy, Download, FileDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -11,15 +12,46 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
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({
|
export function ExportMarkdownButton({
|
||||||
markdown,
|
markdown,
|
||||||
filename,
|
filename,
|
||||||
|
locked = false,
|
||||||
}: {
|
}: {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={
|
||||||
|
<Button variant="ghost" size="icon" aria-label={t("exportMarkdown")} onClick={() => setUpgradeOpen(true)}>
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
} />
|
||||||
|
<TooltipContent className="flex items-center gap-1.5">
|
||||||
|
{t("exportMarkdown")}
|
||||||
|
<ProBadge />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="markdown_export"
|
||||||
|
featureLabel="Markdown export"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,16 +14,24 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { GroceryExportPayload } from "@/lib/grocery-export";
|
import type { GroceryExportPayload } from "@/lib/grocery-export";
|
||||||
import { groceryExportToText } 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 {
|
interface Props {
|
||||||
listId: string;
|
listId: string;
|
||||||
/** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart — otherwise only "copy as text" is offered. */
|
/** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart and the feature is
|
||||||
instacartEnabled: boolean;
|
* 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 t = useTranslations("shoppingLists");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
|
|
||||||
async function fetchPayload(): Promise<GroceryExportPayload | null> {
|
async function fetchPayload(): Promise<GroceryExportPayload | null> {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/export`);
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/export`);
|
||||||
@@ -80,13 +88,20 @@ export function GroceryExportButton({ listId, instacartEnabled }: Props) {
|
|||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
{t("copyAsText")}
|
{t("copyAsText")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{instacartEnabled && (
|
{instacartAvailable && (
|
||||||
<DropdownMenuItem onClick={() => void handleInstacart()}>
|
<DropdownMenuItem onClick={() => (instacartLocked ? setUpgradeOpen(true) : void handleInstacart())}>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
{t("sendToInstacart")}
|
{t("sendToInstacart")}
|
||||||
|
{instacartLocked && <ProBadge className="ml-auto" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
<UpgradeDialog
|
||||||
|
open={upgradeOpen}
|
||||||
|
onOpenChange={setUpgradeOpen}
|
||||||
|
featureKey="grocery_delivery"
|
||||||
|
featureLabel="Grocery delivery integration"
|
||||||
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.78.3";
|
export const APP_VERSION = "0.79.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.78.3",
|
||||||
date: "2026-07-24 15:00",
|
date: "2026-07-24 15:00",
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ export async function getFeatureFlagMatrix(): Promise<Record<FeatureKey, Record<
|
|||||||
return matrix;
|
return matrix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if a feature is enabled for at least one tier — i.e. it's a real
|
||||||
|
* upgrade path, not shut off entirely. UI uses this to decide whether a
|
||||||
|
* locked feature should still show (with a "Pro" upsell) or hide outright:
|
||||||
|
* showing an upsell for something no tier can ever unlock would be a dead
|
||||||
|
* end, so those hide instead. */
|
||||||
|
export function isFeatureAvailableAnyTier(matrix: Record<FeatureKey, Record<Tier, boolean>>, key: FeatureKey): boolean {
|
||||||
|
return TIERS.some((tier) => matrix[key][tier]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setFeatureFlag(
|
export async function setFeatureFlag(
|
||||||
featureKey: FeatureKey,
|
featureKey: FeatureKey,
|
||||||
tier: Tier,
|
tier: Tier,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.78.3",
|
"version": "0.79.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.78.3",
|
"version": "0.79.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user