From fd78a915a665e703575bef648cec52077a01c133 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:42:23 +0200 Subject: [PATCH] feat(activities): useOwnedTickets composable + ActivityCard ticket badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-level singleton so the badge on every ActivityCard, the owned-tickets section on ActivityDetailPage, and the (forthcoming) "My tickets" filter chip on the activity feed all share one fetch of the user's tickets rather than each instance hitting the backend. useOwnedTickets exposes: - ticketsByActivity: Map for O(1) lookup from the card/detail surfaces - ownedActivityIds: Set used by the feed filter - paidCount(id) / getTickets(id) for ergonomic per-activity reads - refresh() for consumers that just mutated the user's ticket set (a successful purchase) to update every surface atomically Auto-loads on first use after auth is ready, re-fetches when the current user id changes (login/logout/switch). ActivityCard grows a primary-colored "You have N tickets" row that sits next to the existing "X tickets remaining" line — buyer can see at a glance whether they've already bought in for any activity in the feed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/components/ActivityCard.vue | 20 +++- .../activities/composables/useOwnedTickets.ts | 113 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/modules/activities/composables/useOwnedTickets.ts 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, + } +}