feat(activities): ticket purchase + Nostr-driven inventory sync #71

Merged
padreug merged 13 commits from tickets-purchase-sync into dev 2026-05-23 21:19:23 +00:00
2 changed files with 132 additions and 1 deletions
Showing only changes of commit fd78a915a6 - Show all commits

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>
Padreug 2026-05-23 20:42:23 +02:00

View file

@ -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') }}
</span>
</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>
</CardContent>
</Card>

View 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,
}
}