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:
parent
fd78a915a6
commit
5589bb3e67
1 changed files with 115 additions and 1 deletions
|
|
@ -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 calendar→Activity 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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue