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:
parent
5ebf0582e0
commit
2498fbe518
2 changed files with 53 additions and 19 deletions
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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…
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue