fix(activities): pause scanner after each decode, require tap to scan next

Without a pause gate the qr-scanner's 5-fps decode loop instantly
fires another scan on whatever QR is still in frame — most
visibly, the ticket that just registered immediately re-fires as
"already scanned this session". The operator at the door never
gets a beat to confirm the result or act on it (let the attendee
in, deny entry, redirect to manual lookup, etc.).

useTicketScanner gains an `isPaused` ref that flips to true the
moment a decode resolves (success, error, or duplicate-session
de-dup) and gates further `onDecode` calls. The camera keeps
streaming so resumption is instant — only the decode handler is
muted.

The page replaces the small "Dismiss" button with a full-width
"Scan next" CTA below the result banner. Same place every time so
the operator's hand can stay in muscle memory; disabled while the
in-flight RPC is still sending. Result banner upgrades to a
slightly larger icon + label so the success/failure is readable
at arm's length over the venue.

`clearScanned` also resets `isPaused` so the operator can recover
from a stuck state via the "Clear list" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-24 18:00:21 +02:00
commit 2498fbe518
2 changed files with 53 additions and 19 deletions

View file

@ -39,6 +39,17 @@ export function useTicketScanner(activityId: Ref<string>) {
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
@ -51,7 +62,7 @@ export function useTicketScanner(activityId: Ref<string>) {
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value) return
if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
@ -60,6 +71,7 @@ export function useTicketScanner(activityId: Ref<string>) {
// of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return
}
@ -90,24 +102,32 @@ export function useTicketScanner(activityId: Ref<string>) {
}
} finally {
isProcessing.value = false
// Pause the decode loop regardless of outcome. The operator
// taps "Scan next" to resume; this guarantees they see the
// banner and can correct (let the attendee in, deny entry,
// etc.) before the next QR comes into frame.
isPaused.value = true
}
}
function resume() {
lastScan.value = null
isPaused.value = false
}
function clearScanned() {
scanned.value = []
lastScan.value = null
}
function dismissLastScan() {
lastScan.value = null
isPaused.value = false
}
return {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
}
}

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2 } from 'lucide-vue-next'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2, ScanLine } from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -19,11 +19,12 @@ const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
dismissLastScan,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
@ -92,8 +93,10 @@ function fmtTime(iso: string) {
</Button>
</div>
<!-- Last-scan banner single line, dismissable. Keeps the
scanner viewport clean while still surfacing the result. -->
<!-- Last-scan result. Sticks until the operator taps "Scan
next" gives them time to verify the outcome and act on
it (let the attendee in, deny entry, etc.) before the
decode loop picks up the next QR. -->
<div
v-if="lastScan"
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
@ -105,18 +108,18 @@ function fmtTime(iso: string) {
>
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-5 h-5 text-emerald-500 shrink-0 mt-0.5"
class="w-6 h-6 text-emerald-500 shrink-0 mt-0.5"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
class="w-6 h-6 text-amber-500 shrink-0 mt-0.5"
/>
<AlertCircle
v-else
class="w-5 h-5 text-destructive shrink-0 mt-0.5"
class="w-6 h-6 text-destructive shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">
<p class="text-base font-semibold text-foreground">
<template v-if="lastScan.status === 'ok'">
Registered
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
@ -130,18 +133,29 @@ function fmtTime(iso: string) {
{{ lastScan.message || 'Scan failed' }}
</template>
</p>
<p class="text-xs font-mono text-muted-foreground break-all mt-0.5">
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
{{ lastScan.ticketId }}
</p>
</div>
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
Dismiss
</Button>
</div>
<!-- Processing hint -->
<!-- "Scan next" CTA primary action when a result is pending,
so the operator's hand goes to the same place every time
(full-width button below the result). Disabled while the
RPC is still in-flight. -->
<Button
v-if="isPaused"
class="w-full mt-3 gap-1.5"
size="lg"
:disabled="isProcessing"
@click="resume"
>
<ScanLine class="w-4 h-4" />
<span v-if="isProcessing">Sending</span>
<span v-else>Scan next</span>
</Button>
<p
v-if="isProcessing"
v-else-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr