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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user