From 5589bb3e67c76a0e0113a203e6d93a7dd601c6c1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:44:15 +0200 Subject: [PATCH] feat(activities): purchase + owned-tickets section on ActivityDetailPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../activities/views/ActivityDetailPage.vue | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 1929ca2..c347363 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -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') +}