feat(activities): purchase + owned-tickets section on ActivityDetailPage

Until now the Purchase button only existed on EventsPage (the
LNbits-sourced listing). Activities sourced from Nostr relays had
no buy path at all. Now that calendar events carry the AIO
tickets_* tags (aiolabs/events#15), the detail page can wire the
existing PurchaseTicketDialog from any activity that has ticketInfo.

Two new blocks appear above the Organizer card when the activity
is ticketed (ticketInfo set):

- Owned-tickets section (primary-tinted card): shown when the
  buyer holds at least one paid ticket. Lists ticket IDs + a
  "View in My Tickets" link.
- Buy ticket CTA: shown when remaining capacity allows. Label
  switches to "Buy another ticket" when the user already owns at
  least one. Price/currency rendered inline so the user knows the
  charge before opening the dialog. A Sold-out message replaces
  the button when available === 0 and the user has no owned
  tickets.

Activity → PurchaseTicketDialog event-shape mapping lives in a
computed so the dialog never receives a partial event. The dialog
itself was untouched (it's the same one EventsPage uses); the
detail page just refreshes useOwnedTickets when the dialog closes
so the badge / section updates immediately after a Lightning
purchase resolves. The inventory side (tickets_available /
tickets_sold counters) updates automatically via the relay
republish from the events extension — no manual refresh needed.

Unauth users get a toast pointing them at login instead of opening
the dialog into a "Login required" state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 20:44:15 +02:00
commit 5589bb3e67

View file

@ -8,15 +8,18 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, Pencil,
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket'
@ -94,6 +97,56 @@ const categoryLabel = computed(() => {
function goBack() {
router.push({ name: 'activities' })
}
// --- Ticket purchase + owned-tickets surface ----------------------
const { getTickets, paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedTicketsForActivity = computed(() => getTickets(activityId))
const ownedPaidCount = computed(() => paidCount(activityId))
const purchaseEvent = computed(() => {
const a = activity.value
if (!a || !a.ticketInfo) return null
return {
id: a.id,
name: a.title,
price_per_ticket: a.ticketInfo.price,
currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency,
}
})
// available === undefined unlimited capacity, button always shown
// available === 0 sold out, button hidden
// available > 0 button shown
const canBuyTicket = computed(() => {
const info = activity.value?.ticketInfo
if (!info) return false
return info.available === undefined || info.available > 0
})
const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
if (!isAuthenticated.value) {
toastService.info('Log in to buy tickets')
return
}
showPurchaseDialog.value = true
}
// Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets()
})
function goToMyTickets() {
router.push('/my-tickets')
}
</script>
<template>
@ -219,6 +272,67 @@ function goBack() {
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). Sections render
bottom-up: existing owned tickets (when count > 0) above
a Purchase CTA (when capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 space-y-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<div class="space-y-1">
<div
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
:key="ticket.id"
class="text-xs font-mono text-muted-foreground break-all"
>
{{ ticket.id }}
</div>
</div>
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
<div v-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0
? t('activities.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
</span>
</Button>
</div>
<p
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('activities.detail.soldOut') }}
</p>
</div>
<PurchaseTicketDialog
v-if="purchaseEvent"
:is-open="showPurchaseDialog"
:event="purchaseEvent"
@update:is-open="showPurchaseDialog = $event"
/>
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">