Files
Epicure/apps/web/app/(app)/meal-plan/page.tsx
T
Arnaud 55c6fc5ab7 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>
2026-07-24 11:16:41 +02:00

210 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer, CalendarDays } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
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 { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {};
function getMonday(dateStr?: string): Date {
// new Date("YYYY-MM-DD") parses as UTC midnight, but getDay() below reads
// local time — in negative UTC-offset zones that's still "yesterday",
// shifting the resolved Monday back by a full week. Parse the y/m/d parts
// directly into local time instead.
const d = dateStr
? (() => {
const [y, m, day] = dateStr.split("-").map(Number);
return new Date(y!, m! - 1, day!);
})()
: new Date();
const dow = d.getDay();
const diff = (dow === 0 ? -6 : 1 - dow);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
// Read local y/m/d, not toISOString() (which converts to UTC and can
// shift the date by a day depending on the server's timezone offset).
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function addWeeks(d: Date, n: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + n * 7);
return copy;
}
export default async function MealPlanPage({
searchParams,
}: {
searchParams: Promise<{ week?: string }>;
}) {
const { week } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
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 monday = getMonday(week);
const weekStart = toDateStr(monday);
const prevWeek = toDateStr(addWeeks(monday, -1));
const nextWeek = toDateStr(addWeeks(monday, 1));
const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1);
const [plan, userRecipes, sharedMemberships, nutritionGoals] = await Promise.all([
db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
entries: {
with: { recipe: { with: { photos: true } }, batchDish: true },
},
},
}),
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true, isBatchCook: true },
with: { batchDishes: { columns: { id: true, name: true }, orderBy: (t, { asc }) => asc(t.order) } },
}),
db.query.mealPlanMembers.findMany({
where: eq(mealPlanMembers.userId, session.user.id),
with: { mealPlan: { with: { user: true } } },
}),
db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session.user.id),
}),
]);
const hasNutritionGoals = !!(
nutritionGoals &&
(nutritionGoals.caloriesKcal || nutritionGoals.proteinG || nutritionGoals.carbsG || nutritionGoals.fatG)
);
const entries = (plan?.entries ?? []).map((e) => ({
id: e.id,
day: e.day,
mealType: e.mealType,
servings: e.servings,
note: e.note,
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
batchDish: e.batchDish ? { id: e.batchDish.id, name: e.batchDish.name } : null,
}));
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
return (
<div className="space-y-6">
<TooltipProvider>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1>
<div className="flex items-center gap-1 mt-1">
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.previousWeek}>
<ChevronLeft className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.previousWeek}</TooltipContent>
</Tooltip>
<p className="text-muted-foreground">{label}</p>
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.nextWeek}>
<ChevronRight className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.nextWeek}</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} mealPlanId={plan?.id} initialIsPublic={plan?.isPublic ?? false} />
<NewShoppingListButton
defaultWeekStart={weekStart}
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
/>
<Tooltip>
<TooltipTrigger render={
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.shoppingLists}>
<ShoppingCart className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.shoppingLists}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.common.print}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.common.print}</TooltipContent>
</Tooltip>
{canExportMarkdown && (
<ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`}
/>
)}
<Tooltip>
<TooltipTrigger render={
<a href={`/api/v1/meal-plans/${weekStart}/export/ics`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.exportCalendar}>
<CalendarDays className="h-4 w-4" />
</a>
} />
<TooltipContent>{msgs.mealPlan.exportCalendar}</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
{canSeeWeeklyNutrition && <WeeklyNutritionBar weekStart={weekStart} />}
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
{sharedMemberships.length > 0 && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">{msgs.mealPlan.sharedWithYou}</h2>
<div className="space-y-2 max-w-lg">
{sharedMemberships.map((membership) => (
<Link
key={membership.id}
href={`/meal-plan/shared/${membership.mealPlan.id}`}
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
>
<div>
<p className="font-medium">{formatMessage(msgs.mealPlan.sharedPlanOf, { name: membership.mealPlan.user?.name ?? "?" })}</p>
<p className="text-sm text-muted-foreground">
{formatMessage(msgs.mealPlan.weekOf, { date: membership.mealPlan.weekStart })} · {msgs.shareDialog[membership.role]}
</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}