diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue
index d021b10..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
@@ -155,19 +159,38 @@ const placeholderBg = computed(() => {
{{ activity.location }}
-
+
-
+
+ {{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
+
+
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
{{ t('activities.detail.soldOut') }}
+
+
+
+
+
+ {{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
+
+
diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts
index 78b1bb5..67b49b5 100644
--- a/src/modules/activities/composables/useActivities.ts
+++ b/src/modules/activities/composables/useActivities.ts
@@ -8,6 +8,7 @@ import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters'
+import { useOwnedTickets } from './useOwnedTickets'
/**
* Main composable for activities discovery.
@@ -17,6 +18,7 @@ export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth()
+ const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false)
const subscriptionError = ref(null)
@@ -70,7 +72,10 @@ export function useActivities() {
const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
)
- return filters.applyFilters(all)
+ const filtered = filters.applyFilters(all)
+ if (!filters.onlyOwnedTickets.value) return filtered
+ const owned = ownedActivityIds.value
+ return filtered.filter(a => owned.has(a.id))
})
/**
diff --git a/src/modules/activities/composables/useActivityFilters.ts b/src/modules/activities/composables/useActivityFilters.ts
index ab3624b..60bcb5e 100644
--- a/src/modules/activities/composables/useActivityFilters.ts
+++ b/src/modules/activities/composables/useActivityFilters.ts
@@ -15,6 +15,13 @@ export function useActivityFilters() {
const temporal = ref(DEFAULT_FILTERS.temporal)
const selectedCategories = ref([])
const selectedDate = ref(undefined)
+ /**
+ * When true, the feed is narrowed to activities the current user
+ * holds at least one paid ticket for. Crossed with the
+ * `ownedActivityIds` set from useOwnedTickets in useActivities
+ * (this composable stays free of ticket fetching).
+ */
+ const onlyOwnedTickets = ref(false)
const filters = computed(() => ({
temporal: temporal.value,
@@ -81,12 +88,18 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
selectedDate.value = undefined
+ onlyOwnedTickets.value = false
+ }
+
+ function toggleOwnedTickets() {
+ onlyOwnedTickets.value = !onlyOwnedTickets.value
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
- selectedDate.value !== undefined
+ selectedDate.value !== undefined ||
+ onlyOwnedTickets.value
)
return {
@@ -94,6 +107,7 @@ export function useActivityFilters() {
temporal,
selectedCategories,
selectedDate,
+ onlyOwnedTickets,
filters,
hasActiveFilters,
@@ -103,6 +117,7 @@ export function useActivityFilters() {
selectDate,
toggleCategory,
clearCategories,
+ toggleOwnedTickets,
resetFilters,
}
}
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