feat: QR code on printed shopping lists

Added the qrcode package (server-side data-URL generation, no client
JS) rather than hand-rolling QR encoding. Only rendered when the list
is public — a private list's /s/ link 404s for anyone who isn't the
owner, so a QR code there would just be a dead scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 22:50:38 +02:00
parent c21157fc93
commit 4e5f45a7e5
8 changed files with 243 additions and 31 deletions
+31 -8
View File
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import QRCode from "qrcode";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
@@ -22,6 +23,13 @@ export default async function ShoppingListPrintPage({ params }: Params) {
if (!list) notFound();
// Only worth printing a scannable link if it actually resolves for whoever
// scans it — a private list's /s/ link 404s for anyone but the owner.
const shareUrl = list.isPublic
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/s/${id}`
: null;
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
const byAisle = list.items.reduce<Record<string, typeof list.items>>((acc, item) => {
const key = item.aisle ?? OTHER;
(acc[key] ??= []).push(item);
@@ -50,6 +58,10 @@ export default async function ShoppingListPrintPage({ params }: Params) {
}
h1 { font-size: 1.8em; margin: 0 0 4px; }
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.qr-block { text-align: center; flex-shrink: 0; }
.qr-block img { width: 88px; height: 88px; display: block; }
.qr-block span { display: block; font-size: 0.65em; color: #999; margin-top: 4px; max-width: 100px; }
h2 { font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; margin: 20px 0 8px; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; border-bottom: 1px dotted #eee; font-size: 0.95em; }
@@ -69,14 +81,25 @@ export default async function ShoppingListPrintPage({ params }: Params) {
<PrintTrigger />
<h1>{list.name}</h1>
<p className="subtitle">
{formatMessage(m.shoppingLists.itemsChecked, {
checked: list.items.filter((i) => i.checked).length,
total: list.items.length,
})}
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
</p>
<div className="header-row">
<div>
<h1>{list.name}</h1>
<p className="subtitle">
{formatMessage(m.shoppingLists.itemsChecked, {
checked: list.items.filter((i) => i.checked).length,
total: list.items.length,
})}
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
</p>
</div>
{qrDataUrl && (
<div className="qr-block">
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL, not an optimizable remote image */}
<img src={qrDataUrl} alt={m.shoppingLists.qrCodeAlt} />
<span>{m.shoppingLists.qrCodeCaption}</span>
</div>
)}
</div>
{aisles.map((aisle) => (
<section key={aisle}>