Compare commits

..

No commits in common. "67a070e9b3de5b72de64a60caa88c3716aa542d6" and "a48e3ace5f0f3eba0982233a966bdfd11d519494" have entirely different histories.

9 changed files with 150 additions and 376 deletions

View file

@ -12,10 +12,6 @@ import type { Activity } from '../types/activity'
const props = defineProps<{
activity: Activity
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -62,46 +58,19 @@ const isPast = computed(() => {
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
// Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed(
() => !!props.activity.lnbitsStatus && props.activity.lnbitsStatus !== 'approved',
)
</script>
<template>
<Card
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', activity)"
>
<!-- Wash-out wrapper. The pending/rejected status badge below sits
OUTSIDE this wrapper so it stays in full color and reads
clearly even when the card is dimmed + desaturated. -->
<div
class="transition-opacity duration-200"
:class="[
compact ? 'flex flex-row' : 'flex flex-col',
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
]"
>
<!-- Compact thumbnail — small square preview on the left of the
row when the event carries an image. `self-center` keeps it
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && activity.image"
:src="activity.image"
:alt="activity.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="activity.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<!-- Image with overlaid badges. Cards without an image skip the
hero area entirely and surface their badges inline at the top
of the content block — the solid-color placeholder + calendar
glyph wasn't communicating anything the title + details don't
already. -->
<div v-if="activity.image" class="relative aspect-[16/9] overflow-hidden">
<img
:src="activity.image"
:alt="activity.title"
@ -137,13 +106,27 @@ const isNonApproved = computed(
{{ priceDisplay }}
</Badge>
<!-- Past badge — shown when the activity has already ended. The
pending/rejected status badge that used to share this slot
is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status
badge is more actionable in that case). -->
<!-- Pending/rejected overlay for the creator's own non-approved
drafts. Only present when the activity originated from a
local LNbits event (Nostr-sourced activities have no
lnbitsStatus). -->
<Badge
v-if="isPast && !isNonApproved"
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<!-- Past badge — shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot — that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
@ -152,26 +135,30 @@ const isNonApproved = computed(
</Badge>
</div>
<CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!activity.image || compact" class="flex flex-wrap items-center gap-1.5">
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Inline badge row (no-image variant). Same badges as the
image-overlay set, just stacked horizontally at the top of
the content area. -->
<div v-if="!activity.image" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="activity.isMine && !compact" variant="outline" class="text-xs gap-1">
<Badge v-if="activity.isMine" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
<Badge
v-if="isPast && !isNonApproved"
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline"
class="text-xs gap-1"
>
@ -180,34 +167,26 @@ const isNonApproved = computed(
</Badge>
</div>
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
own event — bookmarking it would be noise) and clamps the
title to a single line. -->
<!-- Title + Bookmark -->
<div class="flex items-start gap-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
{{ activity.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
<!-- Summary (hidden in compact mode) -->
<!-- Summary -->
<p
v-if="activity.summary && !compact"
v-if="activity.summary"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ activity.summary }}
</p>
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
<div class="mt-auto space-y-1.5 pt-2">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
@ -257,22 +236,5 @@ const isNonApproved = computed(
</div>
</div>
</CardContent>
</div>
<!-- Status badge — absolutely positioned on Card root so it sits
ABOVE the wash-out wrapper and keeps its full color.
Pending + rejected both lean on the destructive token so the
non-approved state reads as "needs attention" in every theme;
the label text differentiates the two specific states.
Bottom-right with a slight downward spill so it anchors
visually without competing with the category chip in the
badge row (full cards) or the thumbnail (compact cards). -->
<Badge
v-if="isNonApproved"
variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</Card>
</template>

View file

@ -7,10 +7,6 @@ import type { Activity } from '../types/activity'
defineProps<{
activities: Activity[]
isLoading?: boolean
/** Render compact rows instead of full-image cards. Used by the
* Hosting view so an operator can scan their roster of events
* without the visual weight of hero images they already recognize. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -43,24 +39,20 @@ const { t } = useI18n()
class="flex flex-col items-center justify-center py-16 text-center"
>
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground">
<h3 class="text-lg font-medium text-foreground mb-1">
{{ t('activities.noActivities') }}
</h3>
<p class="text-sm text-muted-foreground">
{{ t('activities.search.noResults') }}
</p>
</div>
<!-- Activity grid — compact mode collapses to a single column of
tight rows; default mode is the responsive card grid. The
compact gap is bumped a notch so the status badge spilling
past the card's bottom edge has room to sit between cards. -->
<div
v-else
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
>
<!-- Activity grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ActivityCard
v-for="activity in activities"
:key="activity.nostrEventId"
:activity="activity"
:compact="compact"
@click="emit('select', activity)"
/>
</div>

View file

@ -443,7 +443,7 @@ onUnmounted(() => {
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button>
</div>

View file

@ -8,22 +8,35 @@ import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters'
// Filter state is hoisted to module scope so every `useActivities()` /
// `useActivityFilters()` call shares the same refs. The bottom-nav
// Hosting tab in activities-app/App.vue and the feed view in
// ActivitiesPage.vue both rely on this — without a shared instance,
// tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false)
const showPast = ref(false)
/**
* Composable for managing activity filter state and applying filters reactively.
*/
export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this — picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,

View file

@ -188,38 +188,6 @@ export function useTicketScanner(activityId: 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
@ -242,6 +210,5 @@ export function useTicketScanner(activityId: Ref<string>) {
onDecode,
resume,
clearScanned,
registerManually,
}
}

View file

@ -1,31 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Ticket } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useActivities } from '../composables/useActivities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { useAuth } from '@/composables/useAuthService'
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const { allActivities, subscribe } = useActivities()
const { ownedActivityIds } = useOwnedTickets()
const { isAuthenticated } = useAuth()
// Per-page toggle, intentionally not wired to the feed's
// onlyOwnedTickets filter — narrowing the calendar shouldn't also
// narrow the feed the user navigates back to.
const onlyMine = ref(false)
const visibleActivities = computed<Activity[]>(() => {
if (!onlyMine.value) return allActivities.value
const owned = ownedActivityIds.value
return allActivities.value.filter(a => owned.has(a.id))
})
onMounted(() => {
subscribe()
@ -38,23 +19,8 @@ function handleSelectActivity(activity: Activity) {
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<!-- Filter chip: narrows the calendar to events the user has
paid tickets for. Hidden when logged out — nothing to own.
Left-aligned so it doesn't collide with the fixed top-right
hamburger menu. -->
<div v-if="isAuthenticated" class="mb-3 flex">
<Button
:variant="onlyMine ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="onlyMine = !onlyMine"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
</div>
<ActivityCalendarView
:activities="visibleActivities"
:activities="allActivities"
@select-activity="handleSelectActivity"
/>
</div>

View file

@ -90,10 +90,8 @@ function openCalendar() {
<!-- Date picker strip + calendar shortcut. The calendar icon used
to be a bottom-nav tab; it now lives on the right of the week
strip so the tabs row stays focused on the primary views.
Hidden in the Hosting view — operators don't need calendar
navigation when they're managing their own roster. -->
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
strip so the tabs row stays focused on the primary views. -->
<div class="mb-3 flex items-center gap-2">
<DatePickerStrip
class="flex-1 min-w-0"
:selected-date="selectedDate"
@ -114,9 +112,8 @@ function openCalendar() {
column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when past-events or any
categories are active) opens a collapsible that hosts the
past-events toggle + category chips below. Hidden in the
Hosting view — the operator's roster doesn't need them. -->
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
past-events toggle + category chips below. -->
<Collapsible v-model:open="filtersOpen" class="mb-3">
<div class="flex items-start gap-3">
<div class="shrink-0 flex flex-col items-center gap-0.5">
<CollapsibleTrigger as-child>
@ -188,13 +185,10 @@ function openCalendar() {
{{ error }}
</div>
<!-- Activity feed. The Hosting view renders compact rows so the
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<!-- Activity feed -->
<ActivityList
:activities="activities"
:is-loading="isLoading"
:compact="onlyHosting"
@select="handleSelectActivity"
/>
</div>

View file

@ -170,14 +170,36 @@ function goToMyTickets() {
<template>
<div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
<div class="flex items-center mb-4">
<!-- 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">
<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"
size="sm"
class="gap-1.5"
@click="openEditDialog"
aria-label="Edit event"
>
<Pencil class="w-4 h-4" />
Edit
</Button>
</div>
</div>
<!-- Loading -->
@ -209,17 +231,7 @@ function goToMyTickets() {
<!-- Title + bookmark + captions -->
<div>
<div class="flex flex-wrap items-start gap-2 mb-2">
<!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="activity.isMine"
variant="secondary"
class="shrink-0"
>
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0">
{{ categoryLabel }}
</Badge>
<Badge
@ -229,6 +241,13 @@ function goToMyTickets() {
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="activity.isMine"
variant="outline"
class="shrink-0"
>
Yours
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
@ -237,26 +256,11 @@ function goToMyTickets() {
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
</h1>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
v-if="ownedLnbitsEvent"
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('activities.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
</Button>
<!-- Hosts don't need to favorite their own event — the
"Yours" badge already marks it, and the bookmark
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
<BookmarkButton
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
class="shrink-0 mt-1"
/>
</div>
<p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }}
@ -285,40 +289,24 @@ function goToMyTickets() {
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div>
<!-- RSVP — hidden for the host since RSVPing to your own event
is a noise affordance. -->
<!-- RSVP -->
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs
on date-based activities pointing at a non-existent event coord. -->
<RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags — a host always
gets to scan attempts. Stays available for past events too
so the host can still verify attendance after the fact. -->
<Button
v-if="ownedLnbitsEvent"
class="w-full gap-1.5"
size="lg"
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('activities.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets — gated on the activity carrying ticketInfo (set
by the calendar→Activity converter from the AIO custom
tickets_* tags on the published event). Skipped for the
host entirely — they have the Scan CTA above and don't
need a Buy CTA for their own event. -->
<div v-if="activity.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
tickets_* tags on the published event). When the user
already owns tickets, the "you have N tickets / view"
card is promoted (filled primary CTA) and the buy CTA
is demoted (outline). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div
v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import {
ArrowLeft,
CheckCircle2,
@ -10,20 +9,15 @@ import {
Ticket,
ScanLine,
RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
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 { useActivityDetail } from '../composables/useActivityDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute()
const router = useRouter()
@ -41,14 +35,8 @@ const {
refreshStats,
onDecode,
resume,
registerManually,
} = useTicketScanner(activityId)
// 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')
@ -76,55 +64,11 @@ const remainingCount = computed(() => {
return Math.max(0, soldCount.value - registeredCount.value)
})
// 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,
// Registered tickets only — what the "Scanned" tab shows.
const registeredTickets = computed(
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
)
// Fuzzy match on holder name + ticket id. When the search box is
// empty, Fuse returns the list in its incoming order so our
// unregistered-first sort is preserved.
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
allTickets,
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
],
threshold: 0.3,
ignoreLocation: true,
},
matchAllWhenSearchEmpty: true,
},
)
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
@ -212,21 +156,13 @@ function fmtTime(iso: string) {
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<!-- Icon + label wrapped in a real flex container so they
share a gap and items-center alignment. TabsTrigger's
internal slot lives in an inline span, so a `gap-1.5`
on the trigger itself never reaches these two children. -->
<TabsTrigger value="scanner">
<span class="inline-flex items-center justify-center gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</span>
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</TabsTrigger>
<TabsTrigger value="list">
<span class="inline-flex items-center justify-center gap-1.5">
<Ticket class="w-4 h-4" />
Tickets ({{ totalTicketsCount }})
</span>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
Scanned ({{ registeredCount }})
</TabsTrigger>
</TabsList>
@ -254,83 +190,39 @@ function fmtTime(iso: string) {
<TabsContent value="list" class="mt-0">
<div class="space-y-3">
<h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} / {{ totalTicketsCount }} registered
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
· {{ unregisteredCount }} to go
</span>
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
</h2>
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
useFuzzySearch). Empty query → all rows in their
sort order; typing → reordered by relevance. -->
<div v-if="allTickets.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="search"
placeholder="Search by name or ticket id…"
class="pl-8 h-9"
/>
</div>
<!-- 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="searchedTickets.length > 0" class="h-[60vh]">
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="ticket in searchedTickets"
:key="ticket.id"
v-for="record in registeredTickets"
:key="record.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="ticket.registered && ticket.registeredAt"
v-if="record.registeredAt"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
{{ fmtTime(ticket.registeredAt) }}
{{ fmtTime(record.registeredAt) }}
</Badge>
<span v-if="ticket.name" class="font-medium text-foreground">
{{ ticket.name }}
<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">
{{ ticket.id }}
{{ record.id }}
</p>
</div>
<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>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
</li>
</ul>
</ScrollArea>
<p
v-else-if="allTickets.length === 0"
class="text-sm text-muted-foreground text-center py-12"
>
No tickets sold yet.
</p>
<p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets match “{{ searchQuery }}”.
No tickets scanned yet.
</p>
</div>
</TabsContent>