Files
guestguard/frontend/components/CheckInCard.vue
T
Kwaku Danso 003a320690 feat(tier2): day-of check-in — Block H
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>
2026-05-20 17:20:46 +01:00

435 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 doesnt 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>