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