feat(activities): organizer ticket scanner over Nostr transport #73
4 changed files with 338 additions and 1 deletions
feat(activities): organizer ticket scanner over nostr-transport
Closes the activities loop: organizers scan attendees' QRs from the standalone PWA at the door instead of dropping into the LNbits admin register page. Every scan invokes the events_ticket_register RPC (see aiolabs/events#19) over the nostr transport — the organizer's signed kind-21000 event IS the authorization, no admin_key in the browser. - useTicketScanner: stateful driver. Parses `ticket://<id>` URIs, dedups in-session via localStorage (`activities_scanned_<id>`, mirroring the LNbits admin page's `events_scanned_<eventId>` pattern), surfaces lastScan with three states (ok / duplicate- session / error). Backend errors arrive as NostrRpcError messages ("Ticket not paid for", "Ticket already registered", etc.) and render directly. - ScanTicketsPage: camera viewport + last-scan banner + scrollable session list with timestamps and (when available) ticket-holder names. Three banner variants (success/warning/ destructive) so the organizer can read at a glance. - Route /scan/:activityId, gated by requiresAuth. The "Scan" entry button on ActivityDetailPage's top bar is rendered only when `ownedLnbitsEvent !== null`, matching the existing "Edit" gating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
0f8f98d4c5
113
src/modules/activities/composables/useTicketScanner.ts
Normal file
113
src/modules/activities/composables/useTicketScanner.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { ref, type Ref } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { NostrTransportService } from '@/modules/base/services/NostrTransportService'
|
||||
|
||||
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
|
||||
|
||||
export interface ScanResult {
|
||||
status: ScanStatus
|
||||
ticketId: string
|
||||
/** Backend response payload on OK. */
|
||||
ticket?: Record<string, unknown>
|
||||
/** Error string from the backend or local validation. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ScanRecord {
|
||||
ticketId: string
|
||||
/** Holder display name from the backend, if any. */
|
||||
name?: string | null
|
||||
registeredAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
||||
* useQRScanner upstream), the QR decode, the
|
||||
* `events_ticket_register` RPC call, and a session-local scanned
|
||||
* list persisted to localStorage so a page refresh doesn't ask the
|
||||
* organizer to rescan tickets they already counted.
|
||||
*
|
||||
* Mirrors the LNbits admin Quasar register page's
|
||||
* `events_scanned_<eventId>` localStorage key with the
|
||||
* `activities_scanned_<id>` prefix.
|
||||
*/
|
||||
export function useTicketScanner(activityId: Ref<string>) {
|
||||
const transport = injectService<NostrTransportService>(
|
||||
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
|
||||
)
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const lastScan = ref<ScanResult | null>(null)
|
||||
const scanned = useLocalStorage<ScanRecord[]>(
|
||||
() => `activities_scanned_${activityId.value}`,
|
||||
[],
|
||||
)
|
||||
|
||||
function parseTicketId(qrText: string): string {
|
||||
return qrText.startsWith('ticket://')
|
||||
? qrText.slice('ticket://'.length)
|
||||
: qrText
|
||||
}
|
||||
|
||||
async function onDecode(qrText: string): Promise<void> {
|
||||
if (isProcessing.value) return
|
||||
const ticketId = parseTicketId(qrText).trim()
|
||||
if (!ticketId) return
|
||||
|
||||
// Session-local de-dup. Distinct from the backend's "already
|
||||
// registered" — this guards against the QR being held in front
|
||||
// of the camera for multiple decode frames.
|
||||
if (scanned.value.some(r => r.ticketId === ticketId)) {
|
||||
lastScan.value = { status: 'duplicate-session', ticketId }
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
try {
|
||||
const ticket = await transport.call<Record<string, unknown>>(
|
||||
'events_ticket_register',
|
||||
{
|
||||
event_id: activityId.value,
|
||||
ticket_id: ticketId,
|
||||
},
|
||||
)
|
||||
const name = (ticket?.name as string | null | undefined) ?? null
|
||||
scanned.value = [
|
||||
{ ticketId, name, registeredAt: new Date().toISOString() },
|
||||
...scanned.value,
|
||||
]
|
||||
lastScan.value = { status: 'ok', ticketId, ticket }
|
||||
} catch (e) {
|
||||
// Backend RPC errors arrive as NostrRpcError with the
|
||||
// string in `.message`: "Ticket not paid for", "Ticket
|
||||
// already registered", "Ticket does not exist on this
|
||||
// event", "You do not own this event", etc.
|
||||
lastScan.value = {
|
||||
status: 'error',
|
||||
ticketId,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearScanned() {
|
||||
scanned.value = []
|
||||
lastScan.value = null
|
||||
}
|
||||
|
||||
function dismissLastScan() {
|
||||
lastScan.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
isProcessing,
|
||||
lastScan,
|
||||
scanned,
|
||||
onDecode,
|
||||
clearScanned,
|
||||
dismissLastScan,
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({
|
|||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/scan/:activityId',
|
||||
name: 'scan-tickets',
|
||||
component: () => import('./views/ScanTicketsPage.vue'),
|
||||
meta: {
|
||||
title: 'Scan Tickets',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
|
||||
} from 'lucide-vue-next'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
|
|
@ -64,6 +64,10 @@ function openEditDialog() {
|
|||
activitiesStore.showCreateDialog = true
|
||||
}
|
||||
|
||||
function openScannerPage() {
|
||||
router.push({ name: 'scan-tickets', params: { activityId } })
|
||||
}
|
||||
|
||||
const dateDisplay = computed(() => {
|
||||
if (!activity.value) return ''
|
||||
const a = activity.value
|
||||
|
|
@ -157,6 +161,17 @@ function goToMyTickets() {
|
|||
Back
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="openScannerPage"
|
||||
aria-label="Scan tickets"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
|
|
|
|||
200
src/modules/activities/views/ScanTicketsPage.vue
Normal file
200
src/modules/activities/views/ScanTicketsPage.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<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 { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activityId = ref(route.params.activityId as string)
|
||||
const { activity } = useActivityDetail(activityId.value)
|
||||
|
||||
const {
|
||||
isProcessing,
|
||||
lastScan,
|
||||
scanned,
|
||||
onDecode,
|
||||
clearScanned,
|
||||
dismissLastScan,
|
||||
} = useTicketScanner(activityId)
|
||||
|
||||
const scannerOpen = ref(true)
|
||||
|
||||
const lastScanVariant = computed(() => {
|
||||
switch (lastScan.value?.status) {
|
||||
case 'ok':
|
||||
return 'success'
|
||||
case 'duplicate-session':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'destructive'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function handleResult(qrText: string) {
|
||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||
// ticket id at the session-list level.
|
||||
void onDecode(qrText)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try {
|
||||
return format(new Date(iso), 'HH:mm:ss')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-2xl">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ scanned.length }} scanned this session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
||||
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
|
||||
{{ activity.title }}
|
||||
</p>
|
||||
|
||||
<!-- Scanner -->
|
||||
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
|
||||
<QRScanner @result="handleResult" @close="scannerOpen = false" />
|
||||
</div>
|
||||
<div v-else class="flex justify-center my-6">
|
||||
<Button @click="scannerOpen = true" class="gap-1.5">
|
||||
<Ticket class="w-4 h-4" />
|
||||
Resume scanning
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Last-scan banner — single line, dismissable. Keeps the
|
||||
scanner viewport clean while still surfacing the result. -->
|
||||
<div
|
||||
v-if="lastScan"
|
||||
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
|
||||
:class="{
|
||||
'bg-emerald-500/10 border-emerald-500/40': lastScanVariant === 'success',
|
||||
'bg-amber-500/10 border-amber-500/40': lastScanVariant === 'warning',
|
||||
'bg-destructive/10 border-destructive/40': lastScanVariant === 'destructive',
|
||||
}"
|
||||
>
|
||||
<CheckCircle2
|
||||
v-if="lastScanVariant === 'success'"
|
||||
class="w-5 h-5 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"
|
||||
/>
|
||||
<AlertCircle
|
||||
v-else
|
||||
class="w-5 h-5 text-destructive shrink-0 mt-0.5"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
<template v-if="lastScan.status === 'ok'">
|
||||
Registered
|
||||
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
|
||||
— {{ lastScan.ticket.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="lastScan.status === 'duplicate-session'">
|
||||
Already scanned in this session
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ lastScan.message || 'Scan failed' }}
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-xs font-mono text-muted-foreground break-all mt-0.5">
|
||||
{{ lastScan.ticketId }}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Processing hint -->
|
||||
<p
|
||||
v-if="isProcessing"
|
||||
class="text-xs text-center text-muted-foreground mt-3"
|
||||
>
|
||||
Sending registration over Nostr…
|
||||
</p>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<!-- Scanned-this-session list. Persists to localStorage per
|
||||
activity, mirroring the LNbits admin register page's
|
||||
events_scanned_<eventId> pattern. -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-foreground">
|
||||
Scanned ({{ scanned.length }})
|
||||
</h2>
|
||||
<Button
|
||||
v-if="scanned.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5 text-xs"
|
||||
@click="clearScanned"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Clear list
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea v-if="scanned.length > 0" class="h-72">
|
||||
<ul class="space-y-1.5 pr-3">
|
||||
<li
|
||||
v-for="record in scanned"
|
||||
:key="record.ticketId"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="secondary" class="text-[10px] font-mono px-1.5">
|
||||
{{ fmtTime(record.registeredAt) }}
|
||||
</Badge>
|
||||
<span v-if="record.name" class="font-medium text-foreground">
|
||||
{{ record.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ record.ticketId }}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
</li>
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
<p v-else class="text-sm text-muted-foreground text-center py-6">
|
||||
No tickets scanned yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue