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 isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null) 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[]>( const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`, () => `activities_scanned_${activityId.value}`,
[], [],
@ -51,7 +62,7 @@ export function useTicketScanner(activityId: Ref<string>) {
} }
async function onDecode(qrText: string): Promise<void> { async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value) return if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim() const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return if (!ticketId) return
@ -60,6 +71,7 @@ export function useTicketScanner(activityId: Ref<string>) {
// of the camera for multiple decode frames. // of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) { if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId } lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return return
} }
@ -90,24 +102,32 @@ export function useTicketScanner(activityId: Ref<string>) {
} }
} finally { } finally {
isProcessing.value = false 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() { function clearScanned() {
scanned.value = [] scanned.value = []
lastScan.value = null lastScan.value = null
} isPaused.value = false
function dismissLastScan() {
lastScan.value = null
} }
return { return {
isProcessing, isProcessing,
isPaused,
lastScan, lastScan,
scanned, scanned,
onDecode, onDecode,
resume,
clearScanned, clearScanned,
dismissLastScan,
} }
} }

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { format } from 'date-fns'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -19,11 +19,12 @@ const { activity } = useActivityDetail(activityId.value)
const { const {
isProcessing, isProcessing,
isPaused,
lastScan, lastScan,
scanned, scanned,
onDecode, onDecode,
resume,
clearScanned, clearScanned,
dismissLastScan,
} = useTicketScanner(activityId) } = useTicketScanner(activityId)
const scannerOpen = ref(true) const scannerOpen = ref(true)
@ -92,8 +93,10 @@ function fmtTime(iso: string) {
</Button> </Button>
</div> </div>
<!-- Last-scan banner single line, dismissable. Keeps the <!-- Last-scan result. Sticks until the operator taps "Scan
scanner viewport clean while still surfacing the result. --> 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 <div
v-if="lastScan" v-if="lastScan"
class="mt-4 p-4 rounded-lg border flex items-start gap-3" class="mt-4 p-4 rounded-lg border flex items-start gap-3"
@ -105,18 +108,18 @@ function fmtTime(iso: string) {
> >
<CheckCircle2 <CheckCircle2
v-if="lastScanVariant === 'success'" 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 <Clock
v-else-if="lastScanVariant === 'warning'" 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 <AlertCircle
v-else 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"> <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'"> <template v-if="lastScan.status === 'ok'">
Registered Registered
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground"> <span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
@ -130,18 +133,29 @@ function fmtTime(iso: string) {
{{ lastScan.message || 'Scan failed' }} {{ lastScan.message || 'Scan failed' }}
</template> </template>
</p> </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 }} {{ lastScan.ticketId }}
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
Dismiss
</Button>
</div> </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 <p
v-if="isProcessing" v-else-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3" class="text-xs text-center text-muted-foreground mt-3"
> >
Sending registration over Nostr Sending registration over Nostr