Compare commits
No commits in common. "67a070e9b3de5b72de64a60caa88c3716aa542d6" and "a48e3ace5f0f3eba0982233a966bdfd11d519494" have entirely different histories.
67a070e9b3
...
a48e3ace5f
9 changed files with 150 additions and 376 deletions
|
|
@ -12,10 +12,6 @@ import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activity: Activity
|
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<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -62,46 +58,19 @@ const isPast = computed(() => {
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false
|
||||||
return end.getTime() < Date.now()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card
|
<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)"
|
@click="emit('click', activity)"
|
||||||
>
|
>
|
||||||
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
<!-- Image with overlaid badges. Cards without an image skip the
|
||||||
OUTSIDE this wrapper so it stays in full color and reads
|
hero area entirely and surface their badges inline at the top
|
||||||
clearly even when the card is dimmed + desaturated. -->
|
of the content block — the solid-color placeholder + calendar
|
||||||
<div
|
glyph wasn't communicating anything the title + details don't
|
||||||
class="transition-opacity duration-200"
|
already. -->
|
||||||
:class="[
|
<div v-if="activity.image" class="relative aspect-[16/9] overflow-hidden">
|
||||||
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">
|
|
||||||
<img
|
<img
|
||||||
:src="activity.image"
|
:src="activity.image"
|
||||||
:alt="activity.title"
|
:alt="activity.title"
|
||||||
|
|
@ -137,13 +106,27 @@ const isNonApproved = computed(
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Past badge — shown when the activity has already ended. The
|
<!-- Pending/rejected overlay for the creator's own non-approved
|
||||||
pending/rejected status badge that used to share this slot
|
drafts. Only present when the activity originated from a
|
||||||
is now an absolute overlay on Card root, above the wash-out,
|
local LNbits event (Nostr-sourced activities have no
|
||||||
so we still suppress Past when isNonApproved (the status
|
lnbitsStatus). -->
|
||||||
badge is more actionable in that case). -->
|
|
||||||
<Badge
|
<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"
|
variant="outline"
|
||||||
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
>
|
>
|
||||||
|
|
@ -152,26 +135,30 @@ const isNonApproved = computed(
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent
|
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||||
: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). Same badges as the
|
||||||
>
|
image-overlay set, just stacked horizontally at the top of
|
||||||
<!-- Inline badge row (no-image variant + compact variant). Same
|
the content area. -->
|
||||||
badges as the image-overlay set, stacked horizontally at the
|
<div v-if="!activity.image" class="flex flex-wrap items-center gap-1.5">
|
||||||
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">
|
|
||||||
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
|
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="priceDisplay" class="text-xs">
|
<Badge v-if="priceDisplay" class="text-xs">
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Badge>
|
</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" />
|
<User class="w-3 h-3" />
|
||||||
Yours
|
Yours
|
||||||
</Badge>
|
</Badge>
|
||||||
<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"
|
variant="outline"
|
||||||
class="text-xs gap-1"
|
class="text-xs gap-1"
|
||||||
>
|
>
|
||||||
|
|
@ -180,34 +167,26 @@ const isNonApproved = computed(
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
|
<!-- Title + Bookmark -->
|
||||||
own event — bookmarking it would be noise) and clamps the
|
|
||||||
title to a single line. -->
|
|
||||||
<div class="flex items-start gap-1">
|
<div class="flex items-start gap-1">
|
||||||
<h3
|
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
||||||
:class="[
|
|
||||||
'font-semibold text-foreground leading-tight flex-1',
|
|
||||||
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ activity.title }}
|
{{ activity.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
v-if="!compact"
|
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="activity.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="activity.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary (hidden in compact mode) -->
|
<!-- Summary -->
|
||||||
<p
|
<p
|
||||||
v-if="activity.summary && !compact"
|
v-if="activity.summary"
|
||||||
class="text-sm text-muted-foreground line-clamp-2"
|
class="text-sm text-muted-foreground line-clamp-2"
|
||||||
>
|
>
|
||||||
{{ activity.summary }}
|
{{ activity.summary }}
|
||||||
</p>
|
</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 -->
|
<!-- Date/Time -->
|
||||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
|
@ -257,22 +236,5 @@ const isNonApproved = computed(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,6 @@ import type { Activity } from '../types/activity'
|
||||||
defineProps<{
|
defineProps<{
|
||||||
activities: Activity[]
|
activities: Activity[]
|
||||||
isLoading?: boolean
|
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<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -43,24 +39,20 @@ const { t } = useI18n()
|
||||||
class="flex flex-col items-center justify-center py-16 text-center"
|
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" />
|
<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') }}
|
{{ t('activities.noActivities') }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ t('activities.search.noResults') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity grid — compact mode collapses to a single column of
|
<!-- Activity grid -->
|
||||||
tight rows; default mode is the responsive card grid. The
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
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'"
|
|
||||||
>
|
|
||||||
<ActivityCard
|
<ActivityCard
|
||||||
v-for="activity in activities"
|
v-for="activity in activities"
|
||||||
:key="activity.nostrEventId"
|
:key="activity.nostrEventId"
|
||||||
:activity="activity"
|
:activity="activity"
|
||||||
:compact="compact"
|
|
||||||
@click="emit('select', activity)"
|
@click="emit('select', activity)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ onUnmounted(() => {
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Zap class="w-4 h-4 mr-2" />
|
<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>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,35 @@ import type { ActivityCategory } from '../types/category'
|
||||||
import type { TemporalFilter, ActivityFilters } from '../types/filters'
|
import type { TemporalFilter, ActivityFilters } from '../types/filters'
|
||||||
import { DEFAULT_FILTERS } 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.
|
* Composable for managing activity filter state and applying filters reactively.
|
||||||
*/
|
*/
|
||||||
export function useActivityFilters() {
|
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>(() => ({
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
|
||||||
|
|
@ -188,38 +188,6 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
isPaused.value = false
|
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() {
|
function clearScanned() {
|
||||||
scanned.value = []
|
scanned.value = []
|
||||||
lastScan.value = null
|
lastScan.value = null
|
||||||
|
|
@ -242,6 +210,5 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
clearScanned,
|
clearScanned,
|
||||||
registerManually,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { useActivities } from '../composables/useActivities'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
|
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
|
||||||
const { allActivities, subscribe } = useActivities()
|
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(() => {
|
onMounted(() => {
|
||||||
subscribe()
|
subscribe()
|
||||||
|
|
@ -38,23 +19,8 @@ function handleSelectActivity(activity: Activity) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
<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
|
<ActivityCalendarView
|
||||||
:activities="visibleActivities"
|
:activities="allActivities"
|
||||||
@select-activity="handleSelectActivity"
|
@select-activity="handleSelectActivity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,8 @@ function openCalendar() {
|
||||||
|
|
||||||
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
||||||
to be a bottom-nav tab; it now lives on the right of the week
|
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.
|
strip so the tabs row stays focused on the primary views. -->
|
||||||
Hidden in the Hosting view — operators don't need calendar
|
<div class="mb-3 flex items-center gap-2">
|
||||||
navigation when they're managing their own roster. -->
|
|
||||||
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
|
|
||||||
<DatePickerStrip
|
<DatePickerStrip
|
||||||
class="flex-1 min-w-0"
|
class="flex-1 min-w-0"
|
||||||
:selected-date="selectedDate"
|
:selected-date="selectedDate"
|
||||||
|
|
@ -114,9 +112,8 @@ function openCalendar() {
|
||||||
column; only the temporal pills scroll horizontally. The
|
column; only the temporal pills scroll horizontally. The
|
||||||
Filters icon (with a count badge when past-events or any
|
Filters icon (with a count badge when past-events or any
|
||||||
categories are active) opens a collapsible that hosts the
|
categories are active) opens a collapsible that hosts the
|
||||||
past-events toggle + category chips below. Hidden in the
|
past-events toggle + category chips below. -->
|
||||||
Hosting view — the operator's roster doesn't need them. -->
|
<Collapsible v-model:open="filtersOpen" class="mb-3">
|
||||||
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
|
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
||||||
<CollapsibleTrigger as-child>
|
<CollapsibleTrigger as-child>
|
||||||
|
|
@ -188,13 +185,10 @@ function openCalendar() {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity feed. The Hosting view renders compact rows so the
|
<!-- Activity feed -->
|
||||||
operator can scan their roster without the visual weight of
|
|
||||||
hero images they already recognize. -->
|
|
||||||
<ActivityList
|
<ActivityList
|
||||||
:activities="activities"
|
:activities="activities"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:compact="onlyHosting"
|
|
||||||
@select="handleSelectActivity"
|
@select="handleSelectActivity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -170,14 +170,36 @@ function goToMyTickets() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
<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
|
<!-- Top bar -->
|
||||||
prominent icon button; Scan moves into the tickets section
|
<div class="flex items-center justify-between mb-4">
|
||||||
where it replaces the Buy-ticket CTA for the host. -->
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||||
<ArrowLeft class="w-4 h-4" />
|
<ArrowLeft class="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
|
|
@ -209,17 +231,7 @@ function goToMyTickets() {
|
||||||
<!-- Title + bookmark + captions -->
|
<!-- Title + bookmark + captions -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-start gap-2 mb-2">
|
<div class="flex flex-wrap items-start gap-2 mb-2">
|
||||||
<!-- "Yours" leads the row in the highlighted variant so the
|
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0">
|
||||||
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">
|
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -229,6 +241,13 @@ function goToMyTickets() {
|
||||||
>
|
>
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="activity.isMine"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
Yours
|
||||||
|
</Badge>
|
||||||
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,27 +256,12 @@ function goToMyTickets() {
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||||
{{ activity.title }}
|
{{ activity.title }}
|
||||||
</h1>
|
</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
|
<BookmarkButton
|
||||||
v-else
|
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="activity.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="activity.id"
|
||||||
|
class="shrink-0 mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p v-if="activity.summary" class="text-muted-foreground mt-2">
|
<p v-if="activity.summary" class="text-muted-foreground mt-2">
|
||||||
{{ activity.summary }}
|
{{ activity.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -285,40 +289,24 @@ function goToMyTickets() {
|
||||||
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
|
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP — hidden for the host since RSVPing to your own event
|
<!-- RSVP -->
|
||||||
is a noise affordance. -->
|
|
||||||
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
|
<!-- 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
|
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||||
button would default to time-based for every activity, leaving RSVPs
|
button would default to time-based for every activity, leaving RSVPs
|
||||||
on date-based activities pointing at a non-existent event coord. -->
|
on date-based activities pointing at a non-existent event coord. -->
|
||||||
<RSVPButton
|
<RSVPButton
|
||||||
v-if="!ownedLnbitsEvent"
|
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="activity.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="activity.id"
|
||||||
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
: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
|
<!-- Tickets — gated on the activity carrying ticketInfo (set
|
||||||
by the calendar→Activity converter from the AIO custom
|
by the calendar→Activity converter from the AIO custom
|
||||||
tickets_* tags on the published event). Skipped for the
|
tickets_* tags on the published event). When the user
|
||||||
host entirely — they have the Scan CTA above and don't
|
already owns tickets, the "you have N tickets / view"
|
||||||
need a Buy CTA for their own event. -->
|
card is promoted (filled primary CTA) and the buy CTA
|
||||||
<div v-if="activity.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
is demoted (outline). -->
|
||||||
|
<div v-if="activity.ticketInfo" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-if="ownedPaidCount > 0"
|
v-if="ownedPaidCount > 0"
|
||||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<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 { toast } from 'vue-sonner'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -10,20 +9,15 @@ import {
|
||||||
Ticket,
|
Ticket,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
UserCheck,
|
|
||||||
Search,
|
|
||||||
} from 'lucide-vue-next'
|
} 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'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||||
import type { EventTicket } from '../composables/useTicketScanner'
|
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -41,14 +35,8 @@ const {
|
||||||
refreshStats,
|
refreshStats,
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
registerManually,
|
|
||||||
} = useTicketScanner(activityId)
|
} = 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 scannerOpen = ref(true)
|
||||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||||
|
|
||||||
|
|
@ -76,55 +64,11 @@ const remainingCount = computed(() => {
|
||||||
return Math.max(0, soldCount.value - registeredCount.value)
|
return Math.max(0, soldCount.value - registeredCount.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Full ticket roster, sorted so unregistered (actionable) rows lead
|
// Registered tickets only — what the "Scanned" tab shows.
|
||||||
// and registered rows follow most-recent-first. Powers the Tickets
|
const registeredTickets = computed(
|
||||||
// tab where the host can manually register attendees who can prove
|
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
||||||
// 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
function handleResult(qrText: string) {
|
||||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||||
|
|
@ -212,21 +156,13 @@ function fmtTime(iso: string) {
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="w-full">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-2 mb-4">
|
<TabsList class="grid w-full grid-cols-2 mb-4">
|
||||||
<!-- Icon + label wrapped in a real flex container so they
|
<TabsTrigger value="scanner" class="gap-1.5">
|
||||||
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" />
|
<ScanLine class="w-4 h-4" />
|
||||||
Scanner
|
Scanner
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="list">
|
<TabsTrigger value="list" class="gap-1.5">
|
||||||
<span class="inline-flex items-center justify-center gap-1.5">
|
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
Tickets ({{ totalTicketsCount }})
|
Scanned ({{ registeredCount }})
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -254,83 +190,39 @@ function fmtTime(iso: string) {
|
||||||
<TabsContent value="list" class="mt-0">
|
<TabsContent value="list" class="mt-0">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 class="text-sm font-medium text-foreground">
|
<h2 class="text-sm font-medium text-foreground">
|
||||||
{{ registeredCount }} / {{ totalTicketsCount }} registered
|
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
||||||
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
|
|
||||||
· {{ unregisteredCount }} to go
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
|
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
||||||
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]">
|
|
||||||
<ul class="space-y-1.5 pr-3">
|
<ul class="space-y-1.5 pr-3">
|
||||||
<li
|
<li
|
||||||
v-for="ticket in searchedTickets"
|
v-for="record in registeredTickets"
|
||||||
:key="ticket.id"
|
:key="record.id"
|
||||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
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="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
v-if="ticket.registered && ticket.registeredAt"
|
v-if="record.registeredAt"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-[10px] font-mono px-1.5"
|
class="text-[10px] font-mono px-1.5"
|
||||||
>
|
>
|
||||||
{{ fmtTime(ticket.registeredAt) }}
|
{{ fmtTime(record.registeredAt) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span v-if="ticket.name" class="font-medium text-foreground">
|
<span v-if="record.name" class="font-medium text-foreground">
|
||||||
{{ ticket.name }}
|
{{ record.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||||
{{ ticket.id }}
|
{{ record.id }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle2
|
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||||
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</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">
|
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||||
No tickets match “{{ searchQuery }}”.
|
No tickets scanned yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue