Merge pull request 'feat(activities): event name on My tickets + organizer on cards' (#102) from feat/event-name-and-organizer into dev
Reviewed-on: #102
This commit is contained in:
commit
db497d8a06
4 changed files with 154 additions and 110 deletions
|
|
@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
|
import OrganizerCard from './OrganizerCard.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
@ -208,6 +209,15 @@ const isNonApproved = computed(
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
|
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
|
||||||
|
<!-- Organizer — small avatar + display name. Hidden in compact
|
||||||
|
mode (host's own roster, no need to tell them whose event
|
||||||
|
it is) and on cards the user already owns. -->
|
||||||
|
<OrganizerCard
|
||||||
|
v-if="!compact"
|
||||||
|
:pubkey="event.organizer.pubkey"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 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" />
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,37 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { User } from 'lucide-vue-next'
|
import { User } from 'lucide-vue-next'
|
||||||
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
pubkey: string
|
pubkey: string
|
||||||
}>()
|
/** Compact row variant — small avatar, single-line "By <name>".
|
||||||
|
* Used on the events feed card where the organizer is a hint, not
|
||||||
|
* the focus. Default (full) is used on the detail page. */
|
||||||
|
compact?: boolean
|
||||||
|
}>(),
|
||||||
|
{ compact: false },
|
||||||
|
)
|
||||||
|
|
||||||
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-3">
|
<!-- Compact: tiny avatar + "By <name>" on a single line -->
|
||||||
|
<div v-if="compact" class="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
|
||||||
|
<Avatar class="h-4 w-4 shrink-0">
|
||||||
|
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||||
|
<AvatarFallback class="bg-primary/10">
|
||||||
|
<User class="w-2.5 h-2.5 text-primary" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span class="truncate">
|
||||||
|
<template v-if="isLoading">Loading…</template>
|
||||||
|
<template v-else>{{ displayName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full (default): 10x10 avatar with name + nip05/pubkey -->
|
||||||
|
<div v-else class="flex items-center gap-3">
|
||||||
<Avatar class="h-10 w-10">
|
<Avatar class="h-10 w-10">
|
||||||
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||||
<AvatarFallback class="bg-primary/10">
|
<AvatarFallback class="bg-primary/10">
|
||||||
|
|
@ -20,14 +42,14 @@ const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
<template v-if="isLoading">Loading...</template>
|
<template v-if="isLoading">Loading…</template>
|
||||||
<template v-else>{{ displayName }}</template>
|
<template v-else>{{ displayName }}</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
||||||
{{ profile.nip05 }}
|
{{ profile.nip05 }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
||||||
{{ pubkey.slice(0, 16) }}...
|
{{ pubkey.slice(0, 16) }}…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { Event as NostrEvent } from 'nostr-tools'
|
import type { ProfileService, UserProfile } from '@/modules/base/nostr/ProfileService'
|
||||||
|
|
||||||
export interface OrganizerProfile {
|
export interface OrganizerProfile {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
|
|
@ -14,134 +14,92 @@ export interface OrganizerProfile {
|
||||||
website?: string
|
website?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global cache of fetched profiles
|
function fromUserProfile(p: UserProfile): OrganizerProfile {
|
||||||
const profileCache = ref<Map<string, OrganizerProfile>>(new Map())
|
return {
|
||||||
|
pubkey: p.pubkey,
|
||||||
|
name: p.name,
|
||||||
|
displayName: p.display_name,
|
||||||
|
about: p.about,
|
||||||
|
picture: p.picture,
|
||||||
|
nip05: p.nip05,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
|
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
|
||||||
* Uses its own relay subscription to avoid depending on the nostr-feed module.
|
*
|
||||||
|
* Routes through the centralized ProfileService (registered by the base
|
||||||
|
* module) so:
|
||||||
|
* - the cache is shared with every other module that reads kind-0
|
||||||
|
* metadata (nostr-feed, market, chat),
|
||||||
|
* - the subscription waits for the relay hub to actually be connected
|
||||||
|
* before firing (the previous local impl threw synchronously when a
|
||||||
|
* component mounted before connection — silently leaving the user
|
||||||
|
* with a pubkey-truncated fallback even after profiles arrived),
|
||||||
|
* - duplicate fetches for the same pubkey collapse into one
|
||||||
|
* subscription.
|
||||||
*/
|
*/
|
||||||
export function useOrganizerProfile(pubkey: string) {
|
export function useOrganizerProfile(pubkey: string) {
|
||||||
const profile = ref<OrganizerProfile | null>(profileCache.value.get(pubkey) ?? null)
|
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||||
const isLoading = ref(!profile.value)
|
const isLoading = ref(false)
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
function load() {
|
// Reactive read from the shared ProfileService cache. Updates the
|
||||||
if (profileCache.value.has(pubkey)) {
|
// moment a kind-0 lands for this pubkey, regardless of which module
|
||||||
profile.value = profileCache.value.get(pubkey)!
|
// triggered the fetch.
|
||||||
isLoading.value = false
|
const profile = computed<OrganizerProfile | null>(() => {
|
||||||
return
|
if (!profileService) return null
|
||||||
}
|
const p = profileService.profiles.get(pubkey)
|
||||||
|
return p ? fromUserProfile(p) : null
|
||||||
|
})
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const displayName = computed(() => {
|
||||||
if (!relayHub) {
|
const p = profile.value
|
||||||
isLoading.value = false
|
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe = relayHub.subscribe({
|
onMounted(async () => {
|
||||||
id: `profile-${pubkey}-${Date.now()}`,
|
if (!profileService || profile.value) return
|
||||||
filters: [{
|
isLoading.value = true
|
||||||
kinds: [0],
|
|
||||||
authors: [pubkey],
|
|
||||||
limit: 1,
|
|
||||||
}],
|
|
||||||
onEvent: (event: NostrEvent) => {
|
|
||||||
try {
|
try {
|
||||||
const metadata = JSON.parse(event.content)
|
await profileService.getProfile(pubkey)
|
||||||
const p: OrganizerProfile = {
|
} finally {
|
||||||
pubkey,
|
|
||||||
name: metadata.name,
|
|
||||||
displayName: metadata.display_name,
|
|
||||||
about: metadata.about,
|
|
||||||
picture: metadata.picture,
|
|
||||||
banner: metadata.banner,
|
|
||||||
nip05: metadata.nip05,
|
|
||||||
lud16: metadata.lud16,
|
|
||||||
website: metadata.website,
|
|
||||||
}
|
|
||||||
profileCache.value.set(pubkey, p)
|
|
||||||
profile.value = p
|
|
||||||
} catch {
|
|
||||||
// invalid metadata JSON
|
|
||||||
}
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
},
|
|
||||||
onEose: () => {
|
|
||||||
isLoading.value = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
load()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
isLoading,
|
isLoading,
|
||||||
get displayName() {
|
displayName,
|
||||||
const p = profile.value
|
|
||||||
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-fetch profiles for multiple pubkeys (for event cards).
|
* Batch-fetch profiles for multiple pubkeys (for event cards).
|
||||||
|
*
|
||||||
|
* Thin wrapper around ProfileService.fetchProfiles so callers don't
|
||||||
|
* have to know the service token. Useful for warming the cache before
|
||||||
|
* a list of cards mounts.
|
||||||
*/
|
*/
|
||||||
export function useBatchProfiles() {
|
export function useBatchProfiles() {
|
||||||
|
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||||
|
|
||||||
function fetchProfiles(pubkeys: string[]) {
|
function fetchProfiles(pubkeys: string[]) {
|
||||||
const uncached = pubkeys.filter(pk => !profileCache.value.has(pk))
|
if (!profileService || pubkeys.length === 0) return
|
||||||
if (uncached.length === 0) return
|
void profileService.fetchProfiles(pubkeys)
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
|
||||||
if (!relayHub) return
|
|
||||||
|
|
||||||
relayHub.subscribe({
|
|
||||||
id: `batch-profiles-${Date.now()}`,
|
|
||||||
filters: [{
|
|
||||||
kinds: [0],
|
|
||||||
authors: uncached,
|
|
||||||
}],
|
|
||||||
onEvent: (event: NostrEvent) => {
|
|
||||||
try {
|
|
||||||
const metadata = JSON.parse(event.content)
|
|
||||||
profileCache.value.set(event.pubkey, {
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
name: metadata.name,
|
|
||||||
displayName: metadata.display_name,
|
|
||||||
about: metadata.about,
|
|
||||||
picture: metadata.picture,
|
|
||||||
banner: metadata.banner,
|
|
||||||
nip05: metadata.nip05,
|
|
||||||
lud16: metadata.lud16,
|
|
||||||
website: metadata.website,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// skip invalid
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProfile(pubkey: string): OrganizerProfile | undefined {
|
function getProfile(pubkey: string): OrganizerProfile | undefined {
|
||||||
return profileCache.value.get(pubkey)
|
const p = profileService?.profiles.get(pubkey)
|
||||||
|
return p ? fromUserProfile(p) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDisplayName(pubkey: string): string {
|
function getDisplayName(pubkey: string): string {
|
||||||
const p = profileCache.value.get(pubkey)
|
const p = profileService?.profiles.get(pubkey)
|
||||||
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
return p?.display_name ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profiles: profileCache,
|
|
||||||
fetchProfiles,
|
fetchProfiles,
|
||||||
getProfile,
|
getProfile,
|
||||||
getDisplayName,
|
getDisplayName,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import { useUserTickets } from '../composables/useUserTickets'
|
import { useUserTickets } from '../composables/useUserTickets'
|
||||||
|
import { useEvents } from '../composables/useEvents'
|
||||||
|
import { useEventsStore } from '../stores/events'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
@ -22,6 +25,21 @@ const {
|
||||||
refresh
|
refresh
|
||||||
} = useUserTickets()
|
} = useUserTickets()
|
||||||
|
|
||||||
|
// Subscribe to the events feed so we can map ticket.eventId → event.title.
|
||||||
|
// The events store is shared (pinia), so if the user has already visited
|
||||||
|
// the feed this is a no-op fresh subscription; nothing depends on it
|
||||||
|
// being the canonical one.
|
||||||
|
const eventsStore = useEventsStore()
|
||||||
|
const { subscribe: subscribeToEvents } = useEvents()
|
||||||
|
|
||||||
|
function eventTitleFor(eventId: string): string | null {
|
||||||
|
return eventsStore.getEventById(eventId)?.title ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventShortLabel(eventId: string): string {
|
||||||
|
return `Event: ${eventId.slice(0, 8)}…`
|
||||||
|
}
|
||||||
|
|
||||||
const qrCodes = ref<Record<string, string>>({})
|
const qrCodes = ref<Record<string, string>>({})
|
||||||
const currentTicketIndex = ref<Record<string, number>>({})
|
const currentTicketIndex = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
|
@ -110,6 +128,10 @@ watch(groupedTickets, async (newGroups) => {
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Kick off the events subscription so eventTitleFor() can resolve
|
||||||
|
// names as relay events stream in. Fire-and-forget — the QR cards
|
||||||
|
// render fine while the title is still loading.
|
||||||
|
subscribeToEvents()
|
||||||
if (isAuthenticated.value) {
|
if (isAuthenticated.value) {
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
@ -170,9 +192,17 @@ onMounted(async () => {
|
||||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
|
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
<CardTitle class="text-foreground min-w-0 flex-1">
|
||||||
<Badge variant="outline">
|
<RouterLink
|
||||||
|
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||||
|
class="hover:underline truncate block"
|
||||||
|
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||||
|
>
|
||||||
|
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||||
|
</RouterLink>
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" class="shrink-0">
|
||||||
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
|
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,7 +292,15 @@ onMounted(async () => {
|
||||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
<CardTitle class="text-foreground min-w-0">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||||
|
class="hover:underline truncate block"
|
||||||
|
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||||
|
>
|
||||||
|
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||||
|
</RouterLink>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
|
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -279,7 +317,15 @@ onMounted(async () => {
|
||||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
<CardTitle class="text-foreground min-w-0">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||||
|
class="hover:underline truncate block"
|
||||||
|
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||||
|
>
|
||||||
|
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||||
|
</RouterLink>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
|
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -296,7 +342,15 @@ onMounted(async () => {
|
||||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
<CardTitle class="text-foreground min-w-0">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||||
|
class="hover:underline truncate block"
|
||||||
|
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||||
|
>
|
||||||
|
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||||
|
</RouterLink>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
|
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue