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>
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
<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>
|
||||
@@ -69,8 +69,8 @@ const loading = ref(true)
|
||||
// (Collaborators + Branding, configured once) → Analytics (results,
|
||||
// checked periodically). The two action-y tabs anchor the ends; setup
|
||||
// clusters in the middle.
|
||||
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate'
|
||||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate']
|
||||
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate' | 'checkin'
|
||||
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate', 'checkin']
|
||||
function tabFromHash(): EventTab {
|
||||
if (import.meta.client) {
|
||||
const h = window.location.hash.replace('#', '') as EventTab
|
||||
@@ -786,6 +786,7 @@ function checkLabel(band?: string): string {
|
||||
{ id: 'guests', label: 'Guests' },
|
||||
{ id: 'collaborators', label: 'Collaborators' },
|
||||
{ id: 'communications', label: 'Communications' },
|
||||
{ id: 'checkin', label: 'Check-in' },
|
||||
{ id: 'branding', label: 'Branding' },
|
||||
{ id: 'analytics', label: 'Analytics' },
|
||||
{ id: 'gate', label: 'Gate' },
|
||||
@@ -1218,6 +1219,11 @@ function checkLabel(band?: string): string {
|
||||
<CommunicationsCard :event-id="eventId" :your-role="event.your_role" />
|
||||
</div>
|
||||
|
||||
<!-- Check-in (Tier 2 Block H). Door scanner + live arrivals. -->
|
||||
<div v-if="activeTab === 'checkin' && event" class="mt-2">
|
||||
<CheckInCard :event-id="eventId" :your-role="event.your_role" />
|
||||
</div>
|
||||
|
||||
<!-- Gate (Tier 2 Block G). The user-facing rebrand of the fraud
|
||||
detector: strictness presets + trusted networks + decision
|
||||
history, with the technical sliders/CIDR jargon tucked behind
|
||||
|
||||
@@ -37,6 +37,9 @@ interface AccessResponse {
|
||||
can_request_edit_link?: boolean
|
||||
calendar?: CalendarLinks
|
||||
branding?: BrandingPayload | null
|
||||
// Block H — per-guest QR code, only populated for attending RSVPs.
|
||||
// qr_image is a data: URL ready for an <img src>.
|
||||
check_in?: { qr: string; qr_image: string } | null
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
@@ -172,6 +175,18 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
editing.value = false
|
||||
|
||||
// Re-fetch access so the response carries the Block H check-in QR
|
||||
// (the submit endpoint doesn't render the QR — only access does).
|
||||
// Best-effort: a failure here doesn't roll back the successful
|
||||
// RSVP, the guest just won't see the QR until they refresh.
|
||||
try {
|
||||
const path = editNonce.value
|
||||
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
|
||||
: `/access/${token}`
|
||||
const fresh = await useApi<AccessResponse>(path)
|
||||
access.value = fresh
|
||||
} catch { /* non-fatal */ }
|
||||
} catch (e: any) {
|
||||
// BLOCK band returns 403 with the fraud decision; surface it the same
|
||||
// way the first-submit path does.
|
||||
@@ -312,6 +327,29 @@ const submitLabel = computed(() => {
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<!-- Block H — door QR for attending guests. Shown right under
|
||||
"you're confirmed" because that's when the guest is most
|
||||
likely to screenshot or save the email. -->
|
||||
<div
|
||||
v-if="access?.check_in && result.rsvp.response === 'attending'"
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||||
Save for the day
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-zinc-300">
|
||||
Show this code at the door for a quick check-in. Screenshot it now,
|
||||
or look out for the same code on your confirmation email.
|
||||
</p>
|
||||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||||
<img
|
||||
:src="access.check_in.qr_image"
|
||||
alt="Your check-in QR code"
|
||||
class="h-44 w-44"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
Need to change something? You have {{ editsRemaining }}
|
||||
@@ -344,6 +382,25 @@ const submitLabel = computed(() => {
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="access?.check_in && existing.response === 'attending'"
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||||
Your door code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-zinc-300">
|
||||
Show this at the entrance for a quick check-in on the day.
|
||||
</p>
|
||||
<div class="flex items-center justify-center rounded-md border border-zinc-800 bg-white p-3">
|
||||
<img
|
||||
:src="access.check_in.qr_image"
|
||||
alt="Your check-in QR code"
|
||||
class="h-44 w-44"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
|
||||
|
||||
Reference in New Issue
Block a user