A mobile-first single-screen pre-sale site for a 3000-copy mini-book co-publishing event in Wuhan. Built with Next.js 16 (App Router + Cache Components), shadcn/ui, Tailwind v4, and Notion as the database.
The publisher takes payment offline (WeChat QR), then hands buyers a serial number + a whimsical two-word magic word. Buyers come to the site, sign in, complete their profile, and decide whether they want a printed copy mailed from Wuhan.
- 🎴 Single-screen hero with hanging-mini-book SVG, progress meter (
0247 / 3000), and segmented tabs - ✨ Passwordless sign-in via serial + magic word (e.g.
0247+velvet-otter) - 🪪
/adminpanel to walk every row through its lifecycle (Wished → Issued → Shipped → Delivered) — bigger edits (addresses, tracking numbers, typos) still happen directly in Notion - 🌏 Bilingual: Simplified Chinese (default) + English, switchable via a top-bar toggle
- 🧱 Notion-backed persistence with Next.js 16
'use cache'+cacheTagandrevalidateTag('max')for invalidation - 🎨 Custom luxury-press design system (paper, ink, seal, gold; Cormorant Garamond + Inter)
npm install
cp .env.example .env.local # fill in the values, see "Environment variables" below
npm run devVisit http://localhost:3000 — and http://localhost:3000/admin to manage serials.
| Command | Purpose |
|---|---|
npm run dev |
Start the Turbopack dev server |
npm run build |
Production build (Partial Prerender) |
npm run start |
Run the production build |
npm run lint |
ESLint |
All variables live in .env.local (gitignored). See .env.example for the template.
| Variable | Required | What it is |
|---|---|---|
NOTION_TOKEN |
✅ | Internal-integration secret from https://www.notion.so/profile/integrations. Format ntn_... (or legacy secret_...). |
NOTION_DATABASE_ID |
✅ | The 32-char ID of the Doushu Serials database. Used for documentation; reads/writes go through the data source. |
NOTION_DATA_SOURCE_ID |
✅ | The data-source UUID for the same database (Notion 2025-09-03 API). The admin panel and all queries use this. |
NOTION_PARENT_PAGE_ID |
✅ | The 32-char ID of the parent page that holds the database. Lets the admin "Open in Notion" link work. |
NOTION_PARENT_PAGE_URL |
✅ | Full URL of the parent Notion page (https://www.notion.so/<id> or your custom slug). Linked from /admin. |
NOTION_SETTINGS_DATA_SOURCE_ID |
— | Deprecated. No longer read; remove from .env.local if present. |
NOTION_WAITLIST_DATA_SOURCE_ID |
— | Deprecated. Wishlist signups now write into Doushu Serials as Wished rows, so a separate waitlist database is no longer needed. The variable is no longer read; you can remove it from .env.local. |
ADMIN_PASSWORD |
✅ | Password gating /admin. Pick something long. Hashed in transit, compared with timingSafeEqual. |
SESSION_SECRET |
✅ | 32+ random bytes used to HMAC-sign buyer + admin cookies (JOSE HS256). Generate with openssl rand -base64 32. |
ℹ️ When the Notion variables are missing, the homepage still renders with
0 / 3000and the wall shows an empty-state. This is intentional so previews don't crash before configuration.
You only do this once. Walk-through:
- Go to https://www.notion.so/profile/integrations → New integration.
- Name it
Doushu. Workspace = the workspace that should own the data. - Capabilities: Read content, Update content, Insert content.
- Copy the Internal Integration Token → this is your
NOTION_TOKEN.
In Notion, create a top-level page called Doushu · 豆书 (or whatever you like). Copy its URL — you'll get the page ID from the URL.
- A URL like
https://www.notion.so/doushu-3499b256b6b081b78ba6f3135ab7862b→NOTION_PARENT_PAGE_ID = 3499b256b6b081b78ba6f3135ab7862bNOTION_PARENT_PAGE_URL = https://www.notion.so/3499b256b6b081b78ba6f3135ab7862b
Open the page → ··· (top-right) → Connections → search Doushu → Confirm. Without this step the API will 404.
Inside the parent page, add an inline database called Doushu Serials with exactly these properties (names matter — the code keys off them):
| Property | Type | Notes |
|---|---|---|
Serial |
Title | Padded string, e.g. 0247 |
Number |
Number | The integer serial (used for ordering and lookups) |
Magic Word |
Text | Two-word phrase like velvet-otter |
Nickname |
Text | Set by the buyer |
Contact |
Text | WeChat / phone / email |
Address |
Text | Optional, for printed-book exchange |
Wants Printed Book |
Checkbox | |
Show on Wall |
Checkbox | Buyer toggles this in MyBookSheet |
Status |
Select | Options: Wished, Issued, Profile Complete, Exchange Requested, Shipped, Delivered, Cancelled. Every row enters as Wished via the 我想要 QR drawer; the publisher advances it through the pipeline from /admin (or directly in Notion). Postage is collected on delivery; the buyer self-confirms receipt to move Shipped → Delivered. |
Issued At |
Created time | |
Updated At |
Last edited time |
Open the database as a full page. URL looks like https://www.notion.so/<workspace>/<DB_ID>?v=<view>. The 32-char hex chunk before the ? is NOTION_DATABASE_ID.
To get NOTION_DATA_SOURCE_ID (Notion's 2025-09-03 API split databases into one or more "data sources"), call:
curl https://api.notion.com/v1/databases/$NOTION_DATABASE_ID \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: 2025-09-03" | jq '.data_sources[0].id'Or, easier: in the Notion MCP plugin, the database response includes data_sources[].id. Paste that UUID in.
The Doushu Settings database and NOTION_SETTINGS_DATA_SOURCE_ID env var are deprecated — they powered an old admin “ready to ship” toggle. The homepage now always offers both 「我想要」 (waitlist QR) and 「我有一本」 (sign-in).
Where did
Doushu Waitlistgo? It used to be a separate database for the我想要drawer, but waitlist signups now write intoDoushu Serialsdirectly asWishedrows. Nickname uniqueness, the wall, and the lifecycle all flow through the single serials DB. If you have an oldDoushu Waitlistdatabase lying around, you can archive it — the code no longer reads from it.
openssl rand -base64 32 # → SESSION_SECRETChoose a long random string for ADMIN_PASSWORD.
The homepage will show real counts; /admin will list every row and let you walk it through the lifecycle.
Every row enters Doushu Serials the same way: someone taps 我想要 on the homepage and reserves a wish.
- Wish. Visitor opens the site, taps
我想要, types a nickname, taps保存二维码.- The action auto-issues the next serial, generates a magic word, and writes a row with
Status = Wished,Nickname = <them>,Show on Wall = true. - Their chip appears on
心愿墙immediately as a dashed/ghost book showing the reserved serial. - The QR code downloads and the toast confirms
已加入心愿墙 · 编号 0042 · 二维码已开始下载.
- The action auto-issues the next serial, generates a magic word, and writes a row with
- Hand-off. When you deliver a physical mini-book to that person:
- Open
/admin, find their row in the paginated list, read off the serial + magic word, write them on the inside cover. - Flip the row's status from
Wished→Issued. The wall chip flips from ghost to solid.
- Open
- Sign-in. They sign in with that pair, fill in contact info —
Statusauto-moves toProfile Complete. - Optional printed-book request. They open
My book, request the printed edition, type an address —Statusauto-moves toExchange Requested. - Ship. When the printed book leaves your hands, advance the row to
Shippedin/admin(one tap on the dropdown). - Receipt. The buyer taps Confirm receipt in
My bookand the row becomesDelivered— their wall chip gains a green ✓ ("wish fulfilled"). You can also force this from/adminif a buyer never confirms.
Larger fixes (typos in addresses, manual tracking numbers, archiving spam rows) still happen directly in Notion. The site picks up Notion-side edits within ~60s thanks to cacheLife({ revalidate: 60 }), or instantly if a server action triggers a revalidation.
Wall states recap:
Wished→ outlined dashed book chip with serial in muted ink. Reads as "reserved, not delivered yet".Issued/Profile Complete/Exchange Requested/Shipped→ solid colourful book chip with serial. Book is in the pipeline.Delivered→ solid book chip with a green ✓ in the corner. Wish fulfilled.
Note on the 3000 cap. It's a printing target, not a hard cap. Wishes just call
highestSerial() + 1, so the next serial keeps incrementing — you can have more than 3000 wishes on the wall, and the meter visually tops out at the goal.
app/—page.tsx(single-screen hero),admin/page.tsx(manage serials),actions.ts(all server actions, Zod-validated).components/hero/—HeroShell(client),BookStack(animated SVG),ProgressMeter,SegmentedTabs,TopBar.components/sheets/—SignInSheet,MyBookSheet,WaitlistSheet,InfoPanels,StatusTimeline. Bottom sheets keep the experience single-screen on mobile.components/wall/— server-renderedBuyerWall+BookChip(wished / active / fulfilled variants).components/admin/—AdminLogin,ManagePanel(paginated 5-per-page status manager + reset danger zone).components/system/—LangShell+LangSyncso the cookie-based language read can sit inside a<Suspense>boundary required bycacheComponents.lib/notion/—client.ts(singleton),properties.ts(page ↔ row adapter),repo.ts(cached reads via'use cache'+ writes thatrevalidateTag('max', ...)),tags.ts(shared cache-tag constants),status.ts.lib/auth/session.ts— JOSE-signed buyer + admin cookies.lib/i18n.tsx+lib/server-i18n.ts+content/copy.{zh,en}.ts— i18n.lib/words.ts— magic word generator (adj-noun, ~100×100 combinations, normalized for comparison).
Public reads (listWall, counts) use the Next.js 16 'use cache' directive with cacheTag and cacheLife({ stale: 30, revalidate: 60, expire: 3600 }). Writes call revalidateTag('max', WALL_TAG | COUNT_TAG) to invalidate. This means: zero Notion reads on the hot path, eventual consistency under one minute.
The site is platform-agnostic. Server actions and 'use cache' are standard Next.js features, not Vercel-only.
- Vercel: zero-config. Add the env vars under Project Settings → Environment Variables.
- Netlify / Cloudflare Pages: use their official Next.js adapter. Both support server actions on Next.js 16.
- Self-hosted (Node):
npm run build && npm run start. Make sure the env vars are present at runtime, not just at build time. - Docker: build with
--target runner(standard Next.js Dockerfile recipe).
The site should run as a single instance — there's no in-memory state to share, but the cache is per-instance, so multi-instance setups will see each instance independently revalidate.
Private. © Doushu Press.