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:
@@ -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}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.21.0";
|
||||
export const APP_VERSION = "0.22.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.22.0",
|
||||
date: "2026-07-13 22:49",
|
||||
added: [
|
||||
"**QR code on printed shopping lists**: a public list's printout now includes a scannable code linking back to the live version — pairs with public-editable links, so someone at the store can scan and check items off in real time.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.21.0",
|
||||
date: "2026-07-13 22:34",
|
||||
|
||||
@@ -908,6 +908,8 @@
|
||||
"shoppingLists": {
|
||||
"title": "Shopping Lists",
|
||||
"subtitle": "Plan your grocery runs",
|
||||
"qrCodeAlt": "QR code linking to this shopping list",
|
||||
"qrCodeCaption": "Scan to view online",
|
||||
"empty": "No shopping lists yet",
|
||||
"emptyDescription": "Generate one from a meal plan, or start one from scratch.",
|
||||
"listEmpty": "Empty",
|
||||
|
||||
@@ -896,6 +896,8 @@
|
||||
"shoppingLists": {
|
||||
"title": "Listes de courses",
|
||||
"subtitle": "Planifiez vos courses",
|
||||
"qrCodeAlt": "Code QR menant à cette liste de courses",
|
||||
"qrCodeCaption": "Scannez pour voir en ligne",
|
||||
"empty": "Aucune liste de courses",
|
||||
"emptyDescription": "Générez-en une depuis un planning repas, ou créez-en une de zéro.",
|
||||
"listEmpty": "Vide",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -38,6 +38,7 @@
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.80.0",
|
||||
@@ -56,6 +57,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
|
||||
Reference in New Issue
Block a user