003a320690
QR codes on RSVP confirmations, a phone-friendly door scanner, walk-in
support, and a live arrivals widget that updates over WebSocket. Closes
the final Tier 2 block.
Schema (migration 0013)
- check_ins (id, guest_id UNIQUE, checked_in_at, checked_in_by,
arrival_count, notes, walk_in). UNIQUE on guest_id is the
double-check-in guard at the DB layer; signature validation lives
in the QR JWT.
QR JWT
- internal/auth/checkin_qr.go: CheckInQRSigner mints {event_id,
guest_id, exp} payloads with the platform's existing HMAC secret.
Issue() extends expiry to eventDate+24h so a QR minted weeks in
advance still scans on the day. Parse() distinguishes
ErrExpiredJWT from generic ErrInvalidJWT so the API can render a
friendlier 410.
- Unit tests cover round-trip, wrong-secret rejection, expiry
detection, and short-secret refusal at construction time.
Domain + storage
- domain.CheckIn + CheckInSummary
- storage.CheckInRepo: Record (returns ErrAlreadyCheckedIn on the
unique violation), ListByEvent, Summary (arrived headcount,
expected headcount, guests-checked-in count), GuestBelongsToEvent
(belt-and-braces guard against a forged JWT pointing at a
different event's guest).
API
- GET /access/{token} now embeds a check_in payload (raw JWT + a
base64-encoded PNG via skip2/go-qrcode) for attending RSVPs, so
the confirmation page can render the code straight into an <img>.
- POST /events/{id}/check-in — editor+. Validates the QR JWT,
refuses cross-event payloads (400), refuses expired ones (410),
records the row, broadcasts check_in.recorded over the existing
WS hub so the live dashboard updates.
- POST /events/{id}/walk-ins — editor+. Creates the guest + check-in
in one logical op for a door-add who wasn't on the original list.
- GET /events/{id}/check-ins — viewer+. Returns the list and the
summary together so the dashboard widget hydrates in one call.
Frontend
- New CheckInCard.vue: live arrivals widget ("47 of 60 · 78%" plus
a progress bar), recent-arrivals list, Walk-in button, and a
"Start scanning" button that opens a full-screen camera modal.
jsQR loaded from CDN on first open (no bundler dep). Scan
throttling + dedupe prevents the 30fps camera loop from POSTing
N times per paper QR. Successful scan vibrates the phone.
Duplicate (409) → "Already checked in" toast; expired (410) →
"This code has expired"; foreign-event (400) → "doesn't look
like one of your guests".
- New "Check-in" tab on the event-detail page, between
Communications and Branding.
- RSVP confirmation card + revisit card both surface a "Save for
the day" / "Your door code" QR block for attending guests. The
PNG ships pre-rendered from the API so the frontend doesn't need
its own QR library.
- The submit flow now refetches /access after a successful POST so
the QR appears immediately on first submit, not just on revisit.
Tests
- Backend unit tests for the QR signer (round-trip, wrong-secret,
expired, short-secret rejection).
- Integration: TestCheckInHappyPath (scan -> 200, double-scan ->
409, summary reflects arrival), TestCheckInRejectsForeignQR
(event A's JWT can't be used on event B), TestWalkInCreatesGuest
AndCheckIn (door-add creates both rows).
- Full integration suite passes (188.3s, 41 tests / 80+ subtests).
Tier 2 is complete: Blocks A through H all shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
435 lines
15 KiB
Vue
435 lines
15 KiB
Vue
<script setup lang="ts">
|
||
// Tier 2 Block H — day-of check-in. The host opens this on their phone
|
||
// at the venue, taps "Start scanning", and points the camera at each
|
||
// guest's QR. Live arrivals counter updates in place; walk-ins go in
|
||
// via the side button.
|
||
//
|
||
// QR decoding uses jsQR loaded from a CDN at runtime so we don't bloat
|
||
// the regular bundle. The camera is requested only when the scanner
|
||
// is open and stopped on close.
|
||
|
||
interface CheckInRecord {
|
||
id: string
|
||
guest_id: string
|
||
checked_in_at: string
|
||
arrival_count: number
|
||
walk_in: boolean
|
||
notes?: string | null
|
||
}
|
||
|
||
interface Summary {
|
||
arrived_headcount: number
|
||
expected_headcount: number
|
||
guests_checked_in: number
|
||
}
|
||
|
||
interface ListResponse {
|
||
check_ins: CheckInRecord[]
|
||
summary: Summary
|
||
}
|
||
|
||
const props = defineProps<{
|
||
eventId: string
|
||
yourRole?: 'owner' | 'editor' | 'viewer' | null
|
||
}>()
|
||
|
||
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
|
||
|
||
const loading = ref(true)
|
||
const error = ref<string | null>(null)
|
||
const checkIns = ref<CheckInRecord[]>([])
|
||
const summary = ref<Summary>({ arrived_headcount: 0, expected_headcount: 0, guests_checked_in: 0 })
|
||
|
||
// Toast (success / error per scan).
|
||
type Toast = { kind: 'success' | 'warn' | 'error'; text: string }
|
||
const toast = ref<Toast | null>(null)
|
||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||
function showToast(t: Toast, ms = 3000) {
|
||
toast.value = t
|
||
if (toastTimer) clearTimeout(toastTimer)
|
||
toastTimer = setTimeout(() => { toast.value = null }, ms)
|
||
}
|
||
|
||
async function refresh() {
|
||
loading.value = true
|
||
try {
|
||
const data = await useApi<ListResponse>(`/events/${props.eventId}/check-ins`)
|
||
checkIns.value = data.check_ins || []
|
||
summary.value = data.summary
|
||
} catch (e: any) {
|
||
error.value = useErrMessage(e, 'Could not load check-ins')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
onMounted(refresh)
|
||
|
||
// --- camera + scanner ---
|
||
|
||
const scannerOpen = ref(false)
|
||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||
let mediaStream: MediaStream | null = null
|
||
let scanLoopHandle: number | null = null
|
||
const lastScanAt = ref(0)
|
||
const recentlyScanned = ref<Set<string>>(new Set())
|
||
|
||
async function ensureJsQR() {
|
||
// Load jsQR from a CDN once. We attach it to window.jsQR so the
|
||
// scanner loop can call it. If it's already loaded (host opens +
|
||
// closes the scanner repeatedly) reuse the existing copy.
|
||
if (typeof window === 'undefined') return null
|
||
const w = window as any
|
||
if (w.jsQR) return w.jsQR
|
||
await new Promise<void>((resolve, reject) => {
|
||
const s = document.createElement('script')
|
||
s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'
|
||
s.onload = () => resolve()
|
||
s.onerror = () => reject(new Error('Could not load QR scanner script'))
|
||
document.head.appendChild(s)
|
||
})
|
||
return w.jsQR
|
||
}
|
||
|
||
async function openScanner() {
|
||
if (!canEdit.value) return
|
||
try {
|
||
const jsQR = await ensureJsQR()
|
||
if (!jsQR) {
|
||
showToast({ kind: 'error', text: 'QR scanner is not available in this browser.' })
|
||
return
|
||
}
|
||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: 'environment' },
|
||
audio: false,
|
||
})
|
||
scannerOpen.value = true
|
||
await nextTick()
|
||
if (videoRef.value) {
|
||
videoRef.value.srcObject = mediaStream
|
||
await videoRef.value.play()
|
||
scanLoop(jsQR)
|
||
}
|
||
} catch (e: any) {
|
||
showToast({ kind: 'error', text: e?.message || 'Could not start the camera.' })
|
||
}
|
||
}
|
||
|
||
function closeScanner() {
|
||
if (scanLoopHandle !== null) {
|
||
cancelAnimationFrame(scanLoopHandle)
|
||
scanLoopHandle = null
|
||
}
|
||
if (mediaStream) {
|
||
mediaStream.getTracks().forEach((t) => t.stop())
|
||
mediaStream = null
|
||
}
|
||
scannerOpen.value = false
|
||
recentlyScanned.value.clear()
|
||
}
|
||
|
||
function scanLoop(jsQR: any) {
|
||
const video = videoRef.value
|
||
const canvas = canvasRef.value
|
||
if (!video || !canvas) return
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||
if (!ctx) return
|
||
|
||
const tick = () => {
|
||
if (!scannerOpen.value || !videoRef.value || !canvasRef.value) return
|
||
if (videoRef.value.readyState === videoRef.value.HAVE_ENOUGH_DATA) {
|
||
canvas.width = videoRef.value.videoWidth
|
||
canvas.height = videoRef.value.videoHeight
|
||
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
|
||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||
inversionAttempts: 'dontInvert',
|
||
})
|
||
if (code && code.data && shouldProcessScan(code.data)) {
|
||
handleScannedPayload(code.data)
|
||
}
|
||
}
|
||
scanLoopHandle = requestAnimationFrame(tick)
|
||
}
|
||
tick()
|
||
}
|
||
|
||
// shouldProcessScan throttles + dedupes scans. The camera grabs ~30
|
||
// frames per second; without a guard we'd POST 30x for one paper QR.
|
||
function shouldProcessScan(payload: string): boolean {
|
||
const now = Date.now()
|
||
if (now - lastScanAt.value < 1500) return false
|
||
if (recentlyScanned.value.has(payload)) return false
|
||
lastScanAt.value = now
|
||
recentlyScanned.value.add(payload)
|
||
// Expire from the dedupe set after 10s so a guest who legitimately
|
||
// re-scans (e.g. host re-pointing camera at the same code by accident)
|
||
// doesn't get stuck.
|
||
setTimeout(() => recentlyScanned.value.delete(payload), 10_000)
|
||
return true
|
||
}
|
||
|
||
async function handleScannedPayload(payload: string) {
|
||
try {
|
||
const res = await useApi<{ guest: any; summary: Summary; check_in: CheckInRecord }>(
|
||
`/events/${props.eventId}/check-in`,
|
||
{ method: 'POST', body: { qr_payload: payload, arrival_count: 1 } },
|
||
)
|
||
summary.value = res.summary
|
||
checkIns.value = [res.check_in, ...checkIns.value]
|
||
showToast({ kind: 'success', text: `${res.guest?.name || 'Guest'} is in.` })
|
||
if (navigator.vibrate) navigator.vibrate(80)
|
||
} catch (e: any) {
|
||
const status = e?.response?.status ?? e?.statusCode
|
||
if (status === 409) {
|
||
showToast({ kind: 'warn', text: 'Already checked in.' })
|
||
} else if (status === 410) {
|
||
showToast({ kind: 'error', text: 'This code has expired.' })
|
||
} else if (status === 400) {
|
||
showToast({ kind: 'error', text: 'That doesn’t look like one of your guests.' })
|
||
} else {
|
||
showToast({ kind: 'error', text: useErrMessage(e, 'Could not check in') })
|
||
}
|
||
}
|
||
}
|
||
|
||
onUnmounted(closeScanner)
|
||
|
||
// --- walk-in ---
|
||
|
||
const walkInOpen = ref(false)
|
||
const walkInForm = reactive({
|
||
name: '',
|
||
email: '',
|
||
arrival_count: 1,
|
||
notes: '',
|
||
})
|
||
const walkInSubmitting = ref(false)
|
||
|
||
function openWalkIn() {
|
||
walkInForm.name = ''
|
||
walkInForm.email = ''
|
||
walkInForm.arrival_count = 1
|
||
walkInForm.notes = ''
|
||
walkInOpen.value = true
|
||
}
|
||
|
||
async function submitWalkIn() {
|
||
if (!walkInForm.name.trim()) return
|
||
walkInSubmitting.value = true
|
||
try {
|
||
const res = await useApi<{ guest: any; summary: Summary; check_in: CheckInRecord }>(
|
||
`/events/${props.eventId}/walk-ins`,
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
name: walkInForm.name.trim(),
|
||
email: walkInForm.email.trim() || undefined,
|
||
arrival_count: walkInForm.arrival_count,
|
||
notes: walkInForm.notes.trim() || undefined,
|
||
},
|
||
},
|
||
)
|
||
summary.value = res.summary
|
||
checkIns.value = [res.check_in, ...checkIns.value]
|
||
showToast({ kind: 'success', text: `${res.guest?.name || 'Walk-in'} added.` })
|
||
walkInOpen.value = false
|
||
} catch (e: any) {
|
||
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add walk-in') })
|
||
} finally {
|
||
walkInSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
function fmtTime(iso?: string | null) {
|
||
if (!iso) return ''
|
||
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
|
||
}
|
||
|
||
const arrivalPct = computed(() => {
|
||
const exp = summary.value.expected_headcount
|
||
const arr = summary.value.arrived_headcount
|
||
if (exp === 0) return 0
|
||
return Math.min(100, Math.round((arr / exp) * 100))
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="card">
|
||
<header class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold">Check-in</h2>
|
||
<p class="text-xs text-zinc-500">Door scanner and live arrivals.</p>
|
||
</div>
|
||
<div v-if="canEdit" class="flex items-center gap-2">
|
||
<button class="btn-ghost text-sm" @click="openWalkIn">+ Walk-in</button>
|
||
<button class="btn-primary text-sm" @click="openScanner">
|
||
<svg class="mr-1 inline-block h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||
<path d="M4 4a1 1 0 011-1h3a1 1 0 010 2H6v2a1 1 0 11-2 0V4zm0 12a1 1 0 011-1h2v-2a1 1 0 112 0v3a1 1 0 01-1 1H5a1 1 0 01-1-1zM16 4a1 1 0 00-1-1h-3a1 1 0 100 2h2v2a1 1 0 102 0V4zm0 12a1 1 0 01-1 1h-3a1 1 0 110-2h2v-2a1 1 0 112 0v3z" />
|
||
</svg>
|
||
Start scanning
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
|
||
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
||
|
||
<div v-else class="space-y-5">
|
||
<!-- Live arrivals widget. The big number is what a host glances
|
||
at — "are we full yet?" -->
|
||
<div class="rounded-lg border border-brand-700/40 bg-brand-500/[0.06] p-4">
|
||
<div class="flex items-baseline justify-between">
|
||
<div>
|
||
<p class="text-xs font-medium uppercase tracking-widest text-brand-400">Arrived</p>
|
||
<p class="mt-1 text-4xl font-semibold tabular-nums text-brand-200">
|
||
{{ summary.arrived_headcount }}
|
||
<span class="text-base font-normal text-zinc-400">of {{ summary.expected_headcount }}</span>
|
||
</p>
|
||
<p class="mt-1 text-xs text-zinc-400">
|
||
{{ summary.guests_checked_in }}
|
||
{{ summary.guests_checked_in === 1 ? 'guest' : 'guests' }} checked in
|
||
</p>
|
||
</div>
|
||
<div class="text-right">
|
||
<p class="text-3xl font-semibold text-brand-200 tabular-nums">{{ arrivalPct }}%</p>
|
||
<p class="text-xs text-zinc-500">of expected</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-zinc-900">
|
||
<div class="h-full rounded-full bg-brand-500 transition-all" :style="{ width: arrivalPct + '%' }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent arrivals — keeps a host's confidence that scans are
|
||
actually landing. -->
|
||
<div>
|
||
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Recent arrivals</p>
|
||
<p v-if="!checkIns.length" class="text-sm text-zinc-500">No one's checked in yet.</p>
|
||
<ul v-else class="space-y-1.5">
|
||
<li
|
||
v-for="c in checkIns.slice(0, 12)"
|
||
:key="c.id"
|
||
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
|
||
>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-zinc-100">
|
||
Guest checked in
|
||
<span v-if="c.walk_in" class="ml-1 rounded-full bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">walk-in</span>
|
||
</p>
|
||
<p class="text-xs text-zinc-500">
|
||
{{ fmtTime(c.checked_in_at) }} · party of {{ c.arrival_count }}
|
||
</p>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scanner modal. Camera is only mounted while open; closing tears
|
||
down the stream so the camera light goes off. -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="scannerOpen"
|
||
class="fixed inset-0 z-50 flex flex-col bg-black"
|
||
@click.self="closeScanner"
|
||
>
|
||
<div class="flex items-center justify-between px-4 py-3 text-white">
|
||
<p class="text-sm font-medium">Scanning…</p>
|
||
<button class="text-sm text-white/70 hover:text-white" @click="closeScanner">Close</button>
|
||
</div>
|
||
<div class="relative flex-1 overflow-hidden">
|
||
<video ref="videoRef" playsinline class="h-full w-full object-cover"></video>
|
||
<canvas ref="canvasRef" class="hidden"></canvas>
|
||
<!-- A simple frame to guide the host. -->
|
||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<div class="h-64 w-64 rounded-lg border-2 border-brand-500/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.55)]"></div>
|
||
</div>
|
||
</div>
|
||
<div class="px-4 py-3 text-center text-xs text-white/70">
|
||
Hold the guest's code in the frame. Toasts pop when each scan registers.
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- Walk-in modal. -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="walkInOpen"
|
||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||
@click.self="walkInOpen = false"
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
|
||
>
|
||
<h3 class="mb-1 text-base font-semibold">Walk-in</h3>
|
||
<p class="mb-4 text-xs text-zinc-500">
|
||
Adds them to your guest list and marks them checked in.
|
||
</p>
|
||
<form class="space-y-3" @submit.prevent="submitWalkIn">
|
||
<div>
|
||
<label class="label">Name</label>
|
||
<input v-model="walkInForm.name" class="input" required />
|
||
</div>
|
||
<div>
|
||
<label class="label">Email (optional)</label>
|
||
<input v-model="walkInForm.email" type="email" class="input" />
|
||
</div>
|
||
<div>
|
||
<label class="label">Party size</label>
|
||
<input
|
||
v-model.number="walkInForm.arrival_count"
|
||
type="number"
|
||
min="1"
|
||
max="20"
|
||
class="input"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="label">Notes (optional)</label>
|
||
<input v-model="walkInForm.notes" class="input" placeholder="Friend of the bride" />
|
||
</div>
|
||
<div class="flex items-center justify-end gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
class="text-sm text-zinc-400 hover:text-zinc-200"
|
||
:disabled="walkInSubmitting"
|
||
@click="walkInOpen = false"
|
||
>Cancel</button>
|
||
<button
|
||
type="submit"
|
||
class="btn-primary text-sm"
|
||
:disabled="walkInSubmitting || !walkInForm.name.trim()"
|
||
>{{ walkInSubmitting ? 'Adding…' : 'Add walk-in' }}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- Toast. -->
|
||
<Teleport to="body">
|
||
<Transition
|
||
enter-active-class="transition duration-200 ease-out"
|
||
enter-from-class="translate-y-2 opacity-0"
|
||
enter-to-class="translate-y-0 opacity-100"
|
||
leave-active-class="transition duration-200 ease-in"
|
||
leave-from-class="translate-y-0 opacity-100"
|
||
leave-to-class="translate-y-2 opacity-0"
|
||
>
|
||
<button
|
||
v-if="toast"
|
||
type="button"
|
||
class="fixed bottom-6 right-6 z-[60] flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
|
||
:class="toast.kind === 'success' ? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
|
||
: toast.kind === 'warn' ? 'border-amber-700/60 bg-amber-950/90 text-amber-100'
|
||
: 'border-red-800/60 bg-red-950/90 text-red-100'"
|
||
@click="toast = null"
|
||
>{{ toast.text }}</button>
|
||
</Transition>
|
||
</Teleport>
|
||
</section>
|
||
</template>
|