Guild Kiosk — Build Plan
Implementation plan for the self-serve NFC check-in kiosk at Dungeon Books. The backend (Payload CMS, check-in API, loyalty engine, XP system) is complete. This plan covers the remaining work: kiosk frontend, check-in XP wiring, card registration, and auth.
Hardware Setup (Done)
- NFC Reader: ACS WalletMate II (ACR1552U-MW)
- Mode: HID Keyboard only (
00h) — no CCID needed for v1 - Output: Hex, uppercase, no spaces, no start character, Enter as end character
- Buzzer: Beep on card tap (default
0Fh), no beep on removal - Result: tapping an NTAG215 card types
0450CF01635713↵into whatever input is focused - Kiosk hardware: Raspberry Pi 4 + touchscreen monitor (both owned)
What’s Already Built
All in /home/panat/projects/dungeonbooks/guild/:
| Component | File | Status |
|---|---|---|
| Members collection | src/collections/Members.ts | Done — has nfcCardUid (unique text field), nickname, xp, level, class |
| CheckIns collection | src/collections/CheckIns.ts | Done — methods: nfc/qr/manual/phone/event |
| Check-in logic | src/lib/check-in.ts | Done — processCheckIn() with NFC UID lookup, 4-hour dedup, returns member info |
| Check-in API | src/app/api/check-in/route.ts | Done — POST /api/check-in, staff-auth gated |
| Loyalty engine | src/lib/loyalty.ts | Done — awardPoints() accepts source: 'checkin', redeemPoints() |
| XP system | src/lib/xp.ts | Done — BECMI fighter table, detectLevelUp(), levelProgress() |
| Tiers & seed data | src/seed.ts | Done — org: Script Wizards, shop: Dungeon Books, 4 tiers, 4 classes, test member |
| Frontend patterns | src/app/(frontend)/ | Done — Tailwind v4, shadcn/ui, RSC pages + client form components |
| XP bar component | src/app/(frontend)/dashboard/xp-bar.tsx | Done — reusable, shows level/progress/class |
What Needs Building
1. Kiosk Auth Strategy
Problem: The check-in API currently requires a users-collection session (staff login via cookie). A kiosk can’t maintain a browser session reliably — it needs a durable auth mechanism.
Approach: API key auth via server action. Add a kioskApiKey field to the Shops collection. The kiosk client component calls a Next.js server action (not a client-side fetch) that:
- Reads
KIOSK_API_KEYandKIOSK_SHOP_IDfrom server-side env vars (noNEXT_PUBLIC_prefix — never in the client bundle) - Validates the key against the shop’s
kioskApiKeyfield - Calls
processCheckIn()directly (no HTTP round-trip through the API route)
The existing POST /api/check-in route remains unchanged (staff auth via session) — the kiosk bypasses it entirely via the server action. This keeps the API key out of client JS, which matters because the customer base includes developers who will inspect the bundle.
Files to modify:
src/collections/Shops.ts— addkioskApiKeyfieldsrc/app/(kiosk)/kiosk/actions.ts— server action that validates key + calls processCheckIn()
2. Check-in XP Award
Problem: processCheckIn() logs the visit but doesn’t award XP. The awardPoints() function in loyalty.ts already supports source: 'checkin' but nothing calls it.
Approach: After a non-duplicate check-in, call awardPoints() with a flat XP value (100 per GDD, no tier multiplier). Return the XP award and any level-up info in the API response so the kiosk can show it.
Files to modify:
src/lib/check-in.ts— callawardPoints()after creating the check-in record, add XP/level-up toCheckInResult
XP values (from GDD section 10): 100 XP per daily check-in. Tier multipliers do NOT apply to check-in XP (GDD section 3, “XP Multiplier Rule”). awardPoints handles this correctly — for checkin source, XP = raw points (no multiplier).
3. Kiosk Frontend App
Approach: New route group src/app/(kiosk)/ with its own layout (full-screen, no header/nav). Single page that runs in Chromium kiosk mode on the Pi.
Routes:
/kiosk— the main kiosk screen (idle + check-in states)/kiosk/register— staff card registration page (future, can be a Payload admin custom view instead)
Kiosk page states:
- Idle — guild branding, “Tap your guild card” prompt, shop info. A hidden text input is focused and captures HID keyboard input.
- Welcome — member’s character sheet: name/nickname, tier badge, class, XP bar with tick-up animation, level, points balance ($X.XX value). If level-up occurred, show it. Auto-returns to idle after 5 seconds.
- Not Found — “Card not recognized. Ask a Guildmaster!” Auto-returns to idle after 5 seconds.
- Duplicate — same as Welcome (member should feel recognized), but no XP award line. Auto-returns to idle after 5 seconds.
Input capture: The kiosk page renders a hidden <input> that’s always focused. The WalletMate II types hex characters + Enter via HID. On Enter (form submit), the app strips the input, calls the check-in API, and transitions to the appropriate state.
Refocus strategy: onBlur handler immediately re-focuses the input. The touchscreen shouldn’t steal focus since there are no other interactive elements on the idle screen.
Tech details:
- Client component (
'use client') — needsuseState,useRef,useEffectfor input management and state transitions - Calls the
checkInserver action fromactions.ts(no client-side fetch, no exposed API key) - Shop ID read from server-side env var
KIOSK_SHOP_ID(inside the server action) - Auto-reset timer:
setTimeout(() => setState('idle'), 5000)after each check-in - Use existing
XpBarcomponent pattern (or inline the XP display) for the character sheet - Follow existing styling: Tailwind v4 + shadcn/ui components,
cn()for class merging - No animation library (project uses
tw-animate-cssonly) — use CSS transitions for the XP bar tick-up
Files to create:
src/app/(kiosk)/layout.tsx— full-screen layout, imports styles.css, no headersrc/app/(kiosk)/kiosk/page.tsx— RSC shellsrc/app/(kiosk)/kiosk/kiosk-screen.tsx— client component with all state logic
4. Card Registration Flow
For v1: Use Payload admin panel directly. Staff navigates to the member record, taps the NFC card on the reader (it types into the nfcCardUid field), saves. The WalletMate II types the UID in the same format the kiosk expects.
No custom UI needed for v1. The Payload admin’s text field for nfcCardUid works — staff clicks the field, taps the card, UID is typed in, save.
5. RPi Deployment (Separate task, not code)
- Install Chromium, set to kiosk mode:
chromium-browser --kiosk --noerrdialogs http://localhost:3000/kiosk - Auto-start on boot via systemd or
.xinitrc - The guild app runs on the same Pi or connects to the guild server via Tailscale
- If running remotely:
https://<tailscale-hostname>/kiosk
API Response Shape (Updated)
After wiring check-in XP, the POST /api/check-in response becomes:
{
"ok": true,
"member": {
"id": 1,
"name": "Panat",
"tier": "Mithril",
"class": "Mage",
"loyaltyPoints": 12061,
"dollarValue": 120.61,
"xp": 12061,
"level": 4,
"subscriptionStatus": "active"
},
"checkIn": {
"id": 42,
"duplicate": false
},
"xpAwarded": {
"points": 100,
"xp": 100,
"levelUp": {
"previousLevel": 4,
"newLevel": 4,
"leveledUp": false,
"newXp": 12161
}
}
}When duplicate: true, xpAwarded is null (no XP for duplicate check-ins).
Build Order
- Kiosk auth — add API key to Shops, update check-in route
- Check-in XP — wire
awardPointsintoprocessCheckIn, update response shape - Kiosk frontend — build the page with idle/welcome/not-found states
- Test end-to-end — tap card → kiosk shows character sheet with XP award
Verification
pnpm dev— start the guild app- Set a
kioskApiKeyon the Dungeon Books shop in Payload admin - Set
nfcCardUidon the test member to match a physical card (e.g.,0450CF01635713) - Open
/kioskin browser - Tap the NFC card on the WalletMate II
- Verify: welcome screen shows member name, tier, XP bar ticks up by 100, returns to idle after 5s
- Tap again within 4 hours — verify welcome shows but no XP award
- Tap an unregistered card — verify “not found” message
- Run
pnpm test— existing tests should still pass - Write new tests for check-in XP award logic
Related
- guild-kiosk — original kiosk concept and UX spec
- nfc-hardware-research — WalletMate II selection and card decisions
- membership-platform — full system architecture
- dungeon-club-game-design-document — GDD section 10 (NFC Cards & Kiosk)