feat: anonymous read-only public link + QR code for meal plans (v0.68.0)

Mirrors shopping lists' isPublic pattern but read-only only -- no
publicEditable equivalent, since anonymous editing of someone's
weekly meal schedule isn't a real use case the way collaborative
shopping-list editing is. New PATCH /api/v1/meal-plans/{weekStart}
toggles it (lazily creates the week's plan row if missing, same
pattern as the existing POST). Public page at /m/[id] renders the
week grouped by day, linking through to public/unlisted recipes and
just showing the title otherwise.

ShareMealPlanButton gained the same public-link toggle UI as its
shopping-list sibling, plus a QR code (generated client-side via the
already-installed `qrcode` package) for the link once enabled --
handing someone a printed or screenshotted plan doesn't require
typing a URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-21 23:47:31 +02:00
parent c8ee743458
commit 35d4f3d055
17 changed files with 6282 additions and 11 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ export default async function MealPlanPage({
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} />
<ShareMealPlanButton weekStart={weekStart} mealPlanId={plan?.id} initialIsPublic={plan?.isPublic ?? false} />
<NewShoppingListButton
defaultWeekStart={weekStart}
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
@@ -101,7 +101,7 @@ export async function POST(req: NextRequest) {
if (!mealPlan) {
const planId = crypto.randomUUID();
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, isPublic: false, createdAt: new Date() };
}
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, mealPlans, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -41,3 +42,31 @@ export async function POST(req: NextRequest, { params }: Params) {
await db.insert(mealPlans).values({ id, userId: session!.user.id, weekStart });
return NextResponse.json({ id, weekStart }, { status: 201 });
}
const PatchSchema = z.object({
isPublic: z.boolean(),
});
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { weekStart } = await params;
const parsed = PatchSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const existing = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
});
const id = existing?.id ?? crypto.randomUUID();
if (!existing) {
await db.insert(mealPlans).values({ id, userId: session!.user.id, weekStart, isPublic: parsed.data.isPublic });
} else {
await db.update(mealPlans).set({ isPublic: parsed.data.isPublic }).where(eq(mealPlans.id, id));
}
return NextResponse.json({ id, weekStart, isPublic: parsed.data.isPublic });
}
+101
View File
@@ -0,0 +1,101 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Globe } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, eq, and } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages, formatMessage } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
const DAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { id } = await params;
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.id, id), eq(mealPlans.isPublic, true)),
});
if (!plan) return {};
return { title: `Week of ${plan.weekStart}` };
}
export default async function PublicMealPlanPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.id, id), eq(mealPlans.isPublic, true)),
with: {
entries: {
with: { recipe: { columns: { id: true, title: true, visibility: true } } },
},
},
});
if (!plan) notFound();
const byDay = plan.entries.reduce<Record<string, typeof plan.entries>>((acc, entry) => {
(acc[entry.day] ??= []).push(entry);
return acc;
}, {});
return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-3.5 w-3.5" />
<span>{m.mealPlan.publicLinkReadOnlyDescription}</span>
{!session && (
<div className="ml-auto flex items-center gap-2">
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>{m.publicRecipe.logIn}</Link>
</div>
)}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{formatMessage(m.mealPlan.weekOf, { date: plan.weekStart })}</h1>
</div>
<div className="space-y-5">
{DAY_ORDER.filter((day) => byDay[day]?.length).map((day) => (
<div key={day} className="space-y-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">
{m.mealPlan.days[day]}
</h2>
<ul className="space-y-1.5">
{[...byDay[day]!]
.sort((a, b) => MEAL_ORDER.indexOf(a.mealType) - MEAL_ORDER.indexOf(b.mealType))
.map((entry) => (
<li key={entry.id} className="flex items-baseline gap-2 text-sm">
<span className="text-muted-foreground min-w-[5.5rem] shrink-0">{m.mealPlan.meals[entry.mealType]}</span>
{entry.recipe && (entry.recipe.visibility === "public" || entry.recipe.visibility === "unlisted") ? (
<Link href={`/r/${entry.recipe.id}`} className="hover:underline underline-offset-4">
{entry.recipe.title}
</Link>
) : (
<span>{entry.recipe?.title ?? entry.note ?? "—"}</span>
)}
</li>
))}
</ul>
</div>
))}
{plan.entries.length === 0 && (
<p className="text-sm text-muted-foreground">{m.mealPlan.noEntriesPublic}</p>
)}
</div>
{!session && (
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
<p className="font-semibold">{m.publicRecipe.wantToSaveDescription}</p>
<Link href="/signup" className={cn(buttonVariants())}>{m.publicRecipe.getStartedFree}</Link>
</div>
)}
</div>
);
}