feat(activities): event name on My tickets + organizer on cards #102

Merged
padreug merged 2 commits from feat/event-name-and-organizer into dev 2026-06-10 23:10:01 +00:00
4 changed files with 154 additions and 110 deletions

View file

@ -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" />

View file

@ -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(
pubkey: string defineProps<{
}>() 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>

View file

@ -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)
if (!relayHub) {
isLoading.value = false
return
}
unsubscribe = relayHub.subscribe({
id: `profile-${pubkey}-${Date.now()}`,
filters: [{
kinds: [0],
authors: [pubkey],
limit: 1,
}],
onEvent: (event: NostrEvent) => {
try {
const metadata = JSON.parse(event.content)
const p: OrganizerProfile = {
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
},
onEose: () => {
isLoading.value = false
},
})
}
onMounted(() => {
load()
}) })
onUnmounted(() => { const displayName = computed(() => {
if (unsubscribe) { const p = profile.value
unsubscribe() return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
})
onMounted(async () => {
if (!profileService || profile.value) return
isLoading.value = true
try {
await profileService.getProfile(pubkey)
} finally {
isLoading.value = false
} }
}) })
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,

View file

@ -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>