feat(activities): UI tweaks across feed, detail, hosting, calendar, scan, shell #91
2 changed files with 108 additions and 15 deletions
feat(activities): manual ticket registration from the roster tab
The "Scanned" tab becomes "Tickets" and now lists the full event
roster (sold tickets), not just the registered subset. Unregistered
rows lead the list with a Register button so the host can manually
mark someone present without a QR scan — e.g. lost phone, known in
person, or alternate proof of identity.
useTicketScanner gains registerManually(ticketId), which calls the
same PUT /tickets/register/{id} the scanner uses (so it inherits
the event-ownership gate and the unpaid/already-registered backend
checks), then refreshes stats. It skips the scanner pause + full-
screen banner since the operator initiated the action from the
list, and mirrors the session-local dedup so a subsequent QR scan
on the same ticket reports "Already scanned" instead of a duplicate
register round-trip.
The header now reads "registered / total · N to go" so the host
sees roster progress at a glance; failures from the manual register
surface as a sonner toast and the row reverts.
commit
31e48c8f1d
|
|
@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
isPaused.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a ticket as registered without going through the camera —
|
||||
* used when the host knows the attendee in person or accepts an
|
||||
* alternate proof of identity. Same backend endpoint as a scan
|
||||
* (so it also gates on event ownership and rejects unpaid /
|
||||
* already-registered tickets), but skips the scanner pause +
|
||||
* full-screen banner since the operator initiated the action
|
||||
* from the roster directly. Refreshes stats on success.
|
||||
*/
|
||||
async function registerManually(
|
||||
ticketId: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
|
||||
try {
|
||||
await ticketApi.registerTicket(ticketId, adminKey)
|
||||
// Mirror the session-local dedup the scan path uses so a
|
||||
// subsequent QR scan of the same ticket reports "Already
|
||||
// scanned" instead of round-tripping a duplicate register.
|
||||
if (!scanned.value.some(r => r.ticketId === ticketId)) {
|
||||
scanned.value = [
|
||||
{ ticketId, name: null, registeredAt: new Date().toISOString() },
|
||||
...scanned.value,
|
||||
]
|
||||
}
|
||||
await refreshStats()
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
function clearScanned() {
|
||||
scanned.value = []
|
||||
lastScan.value = null
|
||||
|
|
@ -210,5 +242,6 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
onDecode,
|
||||
resume,
|
||||
clearScanned,
|
||||
registerManually,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
Ticket,
|
||||
ScanLine,
|
||||
RefreshCw,
|
||||
UserCheck,
|
||||
} from 'lucide-vue-next'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -17,6 +19,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||
import type { EventTicket } from '../composables/useTicketScanner'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -35,8 +38,14 @@ const {
|
|||
refreshStats,
|
||||
onDecode,
|
||||
resume,
|
||||
registerManually,
|
||||
} = useTicketScanner(eventId)
|
||||
|
||||
// Tracks tickets currently mid-register (manual button click), so each
|
||||
// row can render a per-row spinner without blocking the rest of the
|
||||
// list. A Set keeps add/remove O(1).
|
||||
const pendingRegister = ref<Set<string>>(new Set())
|
||||
|
||||
const scannerOpen = ref(true)
|
||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||
|
||||
|
|
@ -64,11 +73,37 @@ const remainingCount = computed(() => {
|
|||
return Math.max(0, soldCount.value - registeredCount.value)
|
||||
})
|
||||
|
||||
// Registered tickets only — what the "Scanned" tab shows.
|
||||
const registeredTickets = computed(
|
||||
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
||||
// Full ticket roster, sorted so unregistered (actionable) rows lead
|
||||
// and registered rows follow most-recent-first. Powers the Tickets
|
||||
// tab where the host can manually register attendees who can prove
|
||||
// identity but can't present a scannable QR.
|
||||
const allTickets = computed<EventTicket[]>(() => {
|
||||
const list = eventStats.value?.tickets ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.registered !== b.registered) return a.registered ? 1 : -1
|
||||
if (a.registered && b.registered) {
|
||||
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
|
||||
}
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
|
||||
const unregisteredCount = computed(
|
||||
() => allTickets.value.filter(t => !t.registered).length,
|
||||
)
|
||||
|
||||
async function handleManualRegister(ticket: EventTicket) {
|
||||
pendingRegister.value.add(ticket.id)
|
||||
const res = await registerManually(ticket.id)
|
||||
pendingRegister.value.delete(ticket.id)
|
||||
if (res.ok) {
|
||||
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
|
||||
} else {
|
||||
toast.error(res.error || 'Failed to register')
|
||||
}
|
||||
}
|
||||
|
||||
function handleResult(qrText: string) {
|
||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||
|
|
@ -169,7 +204,7 @@ function fmtTime(iso: string) {
|
|||
<TabsTrigger value="list">
|
||||
<span class="inline-flex items-center justify-center gap-1.5">
|
||||
<Ticket class="w-4 h-4" />
|
||||
Scanned ({{ registeredCount }})
|
||||
Tickets ({{ totalTicketsCount }})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
@ -198,39 +233,64 @@ function fmtTime(iso: string) {
|
|||
<TabsContent value="list" class="mt-0">
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-medium text-foreground">
|
||||
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
||||
{{ registeredCount }} / {{ totalTicketsCount }} registered
|
||||
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
|
||||
· {{ unregisteredCount }} to go
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
||||
<!-- Unregistered rows lead the list so the operator can act
|
||||
on the actionable ones first; tap "Register" to mark an
|
||||
attendee present without a QR (e.g. lost phone, known
|
||||
in person). Failures surface as a toast; the row reverts. -->
|
||||
<ScrollArea v-if="allTickets.length > 0" class="h-[60vh]">
|
||||
<ul class="space-y-1.5 pr-3">
|
||||
<li
|
||||
v-for="record in registeredTickets"
|
||||
:key="record.id"
|
||||
v-for="ticket in allTickets"
|
||||
:key="ticket.id"
|
||||
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
|
||||
v-if="record.registeredAt"
|
||||
v-if="ticket.registered && ticket.registeredAt"
|
||||
variant="secondary"
|
||||
class="text-[10px] font-mono px-1.5"
|
||||
>
|
||||
{{ fmtTime(record.registeredAt) }}
|
||||
{{ fmtTime(ticket.registeredAt) }}
|
||||
</Badge>
|
||||
<span v-if="record.name" class="font-medium text-foreground">
|
||||
{{ record.name }}
|
||||
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||
{{ ticket.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ record.id }}
|
||||
{{ ticket.id }}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<CheckCircle2
|
||||
v-if="ticket.registered"
|
||||
class="w-4 h-4 text-emerald-500 shrink-0"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="shrink-0 gap-1"
|
||||
:disabled="pendingRegister.has(ticket.id)"
|
||||
@click="handleManualRegister(ticket)"
|
||||
>
|
||||
<RefreshCw
|
||||
v-if="pendingRegister.has(ticket.id)"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<UserCheck v-else class="w-3.5 h-3.5" />
|
||||
Register
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||
No tickets scanned yet.
|
||||
No tickets sold yet.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue