diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index b93d20a..d16c376 100644 --- a/src/modules/activities/components/ActivityCard.vue +++ b/src/modules/activities/components/ActivityCard.vue @@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n' import { format } from 'date-fns' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next' +import { MapPin, Calendar, Ticket, User, CheckCircle2 } from 'lucide-vue-next' import BookmarkButton from './BookmarkButton.vue' import { useDateLocale } from '../composables/useDateLocale' +import { useOwnedTickets } from '../composables/useOwnedTickets' import type { Activity } from '../types/activity' const props = defineProps<{ @@ -19,6 +20,9 @@ const emit = defineEmits<{ const { t } = useI18n() const { dateLocale } = useDateLocale() +const { paidCount } = useOwnedTickets() + +const ownedCount = computed(() => paidCount(props.activity.id)) const dateDisplay = computed(() => { const a = props.activity @@ -173,6 +177,20 @@ const placeholderBg = computed(() => { {{ t('activities.detail.soldOut') }} + + +
+ + + {{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} + +
diff --git a/src/modules/activities/composables/useOwnedTickets.ts b/src/modules/activities/composables/useOwnedTickets.ts new file mode 100644 index 0000000..5ea5d44 --- /dev/null +++ b/src/modules/activities/composables/useOwnedTickets.ts @@ -0,0 +1,113 @@ +import { computed, ref, watch } from 'vue' +import { useAuth } from '@/composables/useAuthService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { TicketApiService } from '../services/TicketApiService' +import type { ActivityTicket } from '../types/ticket' + +/** + * Module-level singleton: owned-ticket lookup keyed by activity id + * (== LNbits event id == NIP-52 d-tag, all the same string by + * extension contract). Lives at module scope so every + * + the detail page + the feed filter share ONE underlying fetch + * instead of each instance hitting the API. + * + * Auto-loads on first use after auth is ready, and re-loads when + * the current user changes (login/logout). Consumers that mutate the + * user's ticket set (e.g. a successful purchase) call `refresh()` + * directly so every surface reading this composable updates + * atomically. + */ + +const tickets = ref([]) +const isLoading = ref(false) +const error = ref(null) +let hasAutoLoaded = false +let lastLoadedUserId: string | null = null + +async function fetchTickets(): Promise { + const { isAuthenticated, currentUser } = useAuth() + if (!isAuthenticated.value || !currentUser.value) { + tickets.value = [] + lastLoadedUserId = null + return + } + + const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService + const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any + const accessToken = lnbitsAPI?.getAccessToken?.() || '' + + isLoading.value = true + error.value = null + try { + tickets.value = await ticketApi.fetchUserTickets(currentUser.value.id, accessToken) + lastLoadedUserId = currentUser.value.id + } catch (e) { + error.value = e instanceof Error ? e : new Error(String(e)) + tickets.value = [] + } finally { + isLoading.value = false + } +} + +const ticketsByActivity = computed>(() => { + const m = new Map() + for (const ticket of tickets.value) { + const existing = m.get(ticket.activityId) + if (existing) { + existing.push(ticket) + } else { + m.set(ticket.activityId, [ticket]) + } + } + return m +}) + +const ownedActivityIds = computed>(() => { + const s = new Set() + for (const ticket of tickets.value) { + if (ticket.paid) s.add(ticket.activityId) + } + return s +}) + +function getTickets(activityId: string): ActivityTicket[] { + return ticketsByActivity.value.get(activityId) ?? [] +} + +function paidCount(activityId: string): number { + return getTickets(activityId).filter(t => t.paid).length +} + +export function useOwnedTickets() { + const { isAuthenticated, currentUser } = useAuth() + + // First call kicks off the initial load. Subsequent calls just + // attach to the shared state. + if (!hasAutoLoaded) { + hasAutoLoaded = true + fetchTickets() + + // Re-fetch when the current user changes (login / logout / + // account switch). Compares against the last-fetched user id + // so we don't re-fetch when other auth fields update (e.g. + // metadata refresh) without the user id changing. + watch( + () => currentUser.value?.id ?? null, + (id) => { + if (id !== lastLoadedUserId) fetchTickets() + }, + ) + } + + return { + tickets, + ticketsByActivity, + ownedActivityIds, + getTickets, + paidCount, + refresh: fetchTickets, + isLoading, + error, + isAuthenticated, + } +}