feat(activities): useOwnedTickets composable + ActivityCard ticket badge
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<activityId, ActivityTicket[]> 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) <noreply@anthropic.com>
This commit is contained in:
parent
7cf009cff6
commit
fd78a915a6
2 changed files with 132 additions and 1 deletions
|
|
@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -19,6 +20,9 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
|
const { paidCount } = useOwnedTickets()
|
||||||
|
|
||||||
|
const ownedCount = computed(() => paidCount(props.activity.id))
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
const a = props.activity
|
const a = props.activity
|
||||||
|
|
@ -173,6 +177,20 @@ const placeholderBg = computed(() => {
|
||||||
{{ t('activities.detail.soldOut') }}
|
{{ t('activities.detail.soldOut') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Owned tickets — shown when the current user holds at
|
||||||
|
least one paid ticket for this activity. Sits next to
|
||||||
|
the availability line so the buyer can see at a glance
|
||||||
|
whether they've already bought in. -->
|
||||||
|
<div
|
||||||
|
v-if="ownedCount > 0"
|
||||||
|
class="flex items-center gap-1.5 text-sm text-primary"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
113
src/modules/activities/composables/useOwnedTickets.ts
Normal file
113
src/modules/activities/composables/useOwnedTickets.ts
Normal file
|
|
@ -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 <ActivityCard>
|
||||||
|
* + 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<ActivityTicket[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
let hasAutoLoaded = false
|
||||||
|
let lastLoadedUserId: string | null = null
|
||||||
|
|
||||||
|
async function fetchTickets(): Promise<void> {
|
||||||
|
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<Map<string, ActivityTicket[]>>(() => {
|
||||||
|
const m = new Map<string, ActivityTicket[]>()
|
||||||
|
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<Set<string>>(() => {
|
||||||
|
const s = new Set<string>()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue