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
+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>
);
}