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
17 changed files with 698 additions and 95 deletions

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Tomorrow',
thisWeek: 'This Week',
thisMonth: 'This Month',
myTickets: 'My tickets',
},
categories: {
concert: 'Concert',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'When',
tickets: 'Tickets',
ticketsAvailable: '{count} tickets available',
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket',
buyAnotherTicket: 'Buy another ticket',
viewMyTickets: 'View in My Tickets',
soldOut: 'Sold Out',
free: 'Free',
},

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Mañana',
thisWeek: 'Esta semana',
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
},
categories: {
concert: 'Concierto',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'Cuándo',
tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto',
buyAnotherTicket: 'Comprar otro boleto',
viewMyTickets: 'Ver en Mis boletos',
soldOut: 'Agotado',
free: 'Gratis',
},

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Demain',
thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
},
categories: {
concert: 'Concert',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'Quand',
tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet',
buyAnotherTicket: 'Acheter un autre billet',
viewMyTickets: 'Voir dans Mes billets',
soldOut: 'Épuisé',
free: 'Gratuit',
},

View file

@ -58,6 +58,7 @@ export interface LocaleMessages {
tomorrow: string
thisWeek: string
thisMonth: string
myTickets: string
}
categories: Record<string, string>
detail: {
@ -71,6 +72,11 @@ export interface LocaleMessages {
when: string
tickets: string
ticketsAvailable: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string
buyAnotherTicket: string
viewMyTickets: string
soldOut: string
free: string
}

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
@ -155,19 +159,38 @@ const placeholderBg = computed(() => {
<span class="truncate">{{ activity.location }}</span>
</div>
<!-- Tickets available -->
<!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<div
v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available > 0">
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ 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

@ -7,7 +7,7 @@ import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark } from 'lucide-vue-next'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
@ -41,6 +41,7 @@ const {
isLoading,
error,
paymentHash,
paymentRequest,
qrCode,
isPaymentPending,
isPayingWithWallet,
@ -48,14 +49,39 @@ const {
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
ticketQRCode,
purchasedTicketId,
purchasedTicketIds,
showTicketQR
} = useTicketPurchase()
const MAX_QUANTITY = 10
const quantity = ref(1)
const copiedInvoice = ref(false)
function decreaseQuantity() {
if (quantity.value > 1) quantity.value -= 1
}
function increaseQuantity() {
if (quantity.value < MAX_QUANTITY) quantity.value += 1
}
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Older browsers / insecure contexts; the Open-in-wallet button
// still works as a fallback.
}
}
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
@ -147,10 +173,13 @@ async function handlePurchase() {
const method = selectedMethod.value
if (!method) return
// Lightning path: existing composable handles QR + wallet auto-pay.
// Lightning path: the composable just creates the invoice + starts
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
if (method.rail === 'lightning') {
try {
await purchaseTicketForEvent(props.event.id)
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
} catch (err) {
console.error('Error purchasing ticket:', err)
}
@ -177,7 +206,11 @@ async function handlePurchase() {
props.event.id,
userId,
accessToken,
{ paymentMethod: 'fiat', fiatProvider: method.provider },
{
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
@ -206,6 +239,8 @@ function handleClose() {
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
}
onUnmounted(() => {
@ -215,14 +250,20 @@ onUnmounted(() => {
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px]">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" />
Purchase Ticket
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle>
<DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</span>
</DialogDescription>
</DialogHeader>
@ -297,18 +338,50 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span>
</div>
<!-- Quantity selector backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity <= 1"
@click="decreaseQuantity"
>
<Minus class="h-3.5 w-3.5" />
</Button>
<span class="w-6 text-center text-sm font-medium">{{ quantity }}</span>
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity >= MAX_QUANTITY"
@click="increaseQuantity"
>
<Plus class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
<span class="text-sm text-muted-foreground">
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="event.price_per_ticket"
:amount="totalPrice"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
@ -363,76 +436,135 @@ onUnmounted(() => {
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full"
>
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span>
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
<CreditCard class="w-4 h-4" />
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
</span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" />
Pay with Wallet
</span>
<span v-else>Generate Payment Request</span>
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button>
</div>
<!-- Payment QR Code and Status -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold">Payment Required</h3>
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
Processing payment with your wallet...
</p>
<p v-else class="text-sm text-muted-foreground">
Scan the QR code with your Lightning wallet to complete the payment
<!-- Lightning invoice restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
LNbits wallet, scan with an external wallet, or hand off
via lightning: URI on mobile. Polling fires whichever
path the buyer takes. -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p>
</div>
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
<!-- QR + amount + copy/open buttons (restaurant
OrderInvoiceCard pattern). The QR keeps a white background
regardless of theme so phone cameras parse it reliably. -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="qrCode"
:src="qrCode"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="space-y-3 w-full">
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
<Wallet class="w-4 h-4 mr-2" />
Open in Lightning Wallet
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets)
</span>
</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 font-mono text-xs"
@click="copyInvoice"
>
<Check v-if="copiedInvoice" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ copiedInvoice ? 'Copied' : 'Copy' }}
</Button>
<Button
variant="default"
size="sm"
class="flex-1 text-xs"
@click="handleOpenLightningWallet"
>
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button>
</div>
</div>
<div v-if="isPaymentPending" class="text-center space-y-2">
<!-- LNbits-wallet pay button only shown when the buyer is
logged in with a funded wallet. Same screen as the QR so
the user can pick either path without having to back out
of the dialog. -->
<Button
v-if="hasWalletWithBalance"
size="lg"
class="w-full"
:disabled="isPayingWithWallet"
@click="payCurrentInvoiceWithWallet"
>
<Loader2 v-if="isPayingWithWallet" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ isPayingWithWallet ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button>
<p
v-else-if="userWallets.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty pay with an external wallet
using the QR or "Open in wallet" above.
</p>
<div v-if="isPaymentPending" class="text-center space-y-1">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
Waiting for payment
</span>
</div>
<p class="text-xs text-muted-foreground">
Payment will be confirmed automatically once received
Confirmation lands automatically no need to refresh.
</p>
</div>
</div>
</div>
<!-- Ticket QR Code (After Successful Purchase) -->
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
<p class="text-sm text-muted-foreground">
Your ticket has been purchased and is now available in your tickets area.
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 w-full">
<div class="text-center space-y-3">
<!-- Success state. QRs live in My Tickets no need to
pre-render them here; this view's job is to confirm the
purchase landed and route the buyer to where they actually
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
</div>
<div>
<p class="text-sm font-medium">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
</div>
</div>
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">
{{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }}
</h3>
<p class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets
hand them out independently for the door scan.
</span>
<span v-else>
Your ticket is now in My Tickets.
</span>
</p>
</div>
<div class="space-y-3 w-full">

View file

@ -8,6 +8,7 @@ import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters'
import { useOwnedTickets } from './useOwnedTickets'
/**
* Main composable for activities discovery.
@ -17,6 +18,7 @@ export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null)
@ -70,7 +72,10 @@ export function useActivities() {
const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
)
return filters.applyFilters(all)
const filtered = filters.applyFilters(all)
if (!filters.onlyOwnedTickets.value) return filtered
const owned = ownedActivityIds.value
return filtered.filter(a => owned.has(a.id))
})
/**

View file

@ -15,6 +15,13 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
@ -81,12 +88,18 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
selectedDate.value = undefined
onlyOwnedTickets.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
selectedDate.value !== undefined
selectedDate.value !== undefined ||
onlyOwnedTickets.value
)
return {
@ -94,6 +107,7 @@ export function useActivityFilters() {
temporal,
selectedCategories,
selectedDate,
onlyOwnedTickets,
filters,
hasActiveFilters,
@ -103,6 +117,7 @@ export function useActivityFilters() {
selectDate,
toggleCategory,
clearCategories,
toggleOwnedTickets,
resetFilters,
}
}

View file

@ -0,0 +1,127 @@
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) ?? []
}
/** Number of paid ticket rows for an activity. With the
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
* this matches the number of attendees / scannable QRs. */
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 + sets up the auth-change
// watcher. Subsequent calls 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()
},
)
} else if (
!isLoading.value &&
isAuthenticated.value &&
currentUser.value &&
lastLoadedUserId !== currentUser.value.id
) {
// A previous load failed (lastLoadedUserId stayed null) or the
// user changed identity while the singleton was idle. Retry —
// the buyer landing on a fresh detail page after a transient
// backend hiccup shouldn't be stuck with empty tickets.
fetchTickets()
}
return {
tickets,
ticketsByActivity,
ownedActivityIds,
getTickets,
paidCount,
refresh: fetchTickets,
isLoading,
error,
isAuthenticated,
}
}

View file

@ -20,9 +20,16 @@ export function useTicketPurchase() {
const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false)
// Ticket QR code state
// Ticket QR code state. After payment lands, `purchasedTicketIds`
// is populated with every row id created on the invoice (one for
// a single-ticket purchase, N for multi). `ticketQRCodes` is a
// parallel map id → QR data URL so the UI can render one QR per
// attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path.
const ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false)
// Computed properties
@ -75,7 +82,15 @@ export function useTicketPurchase() {
}
}
async function purchaseTicketForEvent(eventId: string) {
/** The event id this composable is currently driving kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null)
async function purchaseTicketForEvent(
eventId: string,
options: { quantity?: number } = {},
) {
if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets')
}
@ -86,8 +101,11 @@ export function useTicketPurchase() {
paymentRequest.value = null
qrCode.value = null
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -96,7 +114,8 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket(
eventId,
currentUser.value!.id,
accessToken
accessToken,
{ quantity: options.quantity },
)
// Backend now returns either a Lightning invoice or a fiat
@ -119,18 +138,12 @@ export function useTicketPurchase() {
// Generate QR code for payment
await generateQRCode(bolt11)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
try {
await payWithWallet(bolt11)
// Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
} else {
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
return invoice
}, {
@ -138,6 +151,19 @@ export function useTicketPurchase() {
})
}
/**
* Trigger LNbits-wallet payment of the invoice this composable is
* currently displaying. Called when the buyer clicks the "Pay from
* my LNbits wallet" button on the invoice screen.
*/
async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return
await payWithWallet(paymentRequest.value)
// Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to
// restart it here.
}
async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true
let checkInterval: number | null = null
@ -152,13 +178,34 @@ export function useTicketPurchase() {
clearInterval(checkInterval)
}
if (result.ticketId) {
purchasedTicketId.value = result.ticketId
await generateTicketQRCode(result.ticketId)
// Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include
// `ticketId` only. Render one QR per row so each attendee
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds
: result.ticketId
? [result.ticketId]
: []
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
}
toast.success('Ticket purchased successfully!')
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
}
} catch (err) {
console.error('Error checking payment status:', err)
@ -180,7 +227,9 @@ export function useTicketPurchase() {
qrCode.value = null
isPaymentPending.value = false
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
}
@ -208,7 +257,9 @@ export function useTicketPurchase() {
isPaymentPending,
isPayingWithWallet,
ticketQRCode,
ticketQRCodes,
purchasedTicketId,
purchasedTicketIds,
showTicketQR,
// Computed
@ -219,6 +270,7 @@ export function useTicketPurchase() {
// Actions
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
cleanup,

View file

@ -66,6 +66,7 @@ export function useUserTickets() {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
})
const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>()

View file

@ -75,6 +75,8 @@ export class TicketApiService {
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
@ -83,6 +85,7 @@ export class TicketApiService {
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const data = await this.request(
`/events/api/v1/tickets/${eventId}`,
@ -120,6 +123,7 @@ export class TicketApiService {
return {
paid: data.paid === true,
ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
}
}

View file

@ -1,6 +1,6 @@
import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket'
/**
@ -74,8 +74,26 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo {
price: number
currency: string
available: number
total: number
/** Remaining capacity. Undefined means unlimited. */
available?: number
/** Running paid count. */
sold: number
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
if (!ticket) return undefined
return {
price: ticket.price,
currency: ticket.currency,
available: ticket.available,
sold: ticket.sold,
allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency,
}
}
/**
@ -104,6 +122,7 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
geohash: event.geohash,
category,
tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
createdAt: new Date(event.createdAt * 1000),
}
@ -140,6 +159,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
geohash: event.geohash,
category,
tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
createdAt: new Date(event.createdAt * 1000),
}

View file

@ -17,6 +17,27 @@ export const NIP52_KINDS = {
export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS]
/**
* AIO custom tags carried on ticketed calendar events. The aiolabs/events
* extension adds these so connected clients can render the buy CTA + the
* "X tickets remaining" badge without an extra REST hop. Absent when the
* event was published by a non-AIO client.
*/
export interface TicketTags {
/** Remaining capacity. Undefined means unlimited. */
available?: number
/** Running paid-count. */
sold: number
/** Price per ticket in the event's `currency`. */
price: number
/** Currency string (e.g. 'sat', 'sats', 'USD'). */
currency: string
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
/**
* Parsed NIP-52 date-based calendar event (kind 31922)
*/
@ -36,6 +57,7 @@ export interface CalendarDateEvent {
references: string[]
id: string
createdAt: number
ticket?: TicketTags
}
/**
@ -59,6 +81,7 @@ export interface CalendarTimeEvent {
references: string[]
id: string
createdAt: number
ticket?: TicketTags
}
export interface Participant {
@ -96,6 +119,35 @@ function getTagValues(tags: string[][], tagName: string): string[] {
return tags.filter(t => t[0] === tagName).map(t => t[1])
}
/**
* Parse the AIO ticket_* tags off a NIP-52 calendar event. Returns
* undefined when the event carries no ticket info (e.g. an event
* published by a non-AIO client or a non-ticketed AIO event though
* the latter doesn't currently exist since every aiolabs/events row
* has a price + currency).
*
* `tickets_currency` is the discriminator: when absent, the event has
* no inventory metadata and the buy UI stays hidden.
*/
function parseTicketTags(tags: string[][]): TicketTags | undefined {
const currency = getTagValue(tags, 'tickets_currency')
if (!currency) return undefined
const availableStr = getTagValue(tags, 'tickets_available')
const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
return {
available: availableStr != null ? Number(availableStr) : undefined,
sold: soldStr != null ? Number(soldStr) : 0,
price: priceStr != null ? Number(priceStr) : 0,
currency,
allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
}
}
/**
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings.
@ -166,6 +218,7 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
references: getTagValues(event.tags, 'r'),
id: event.id,
createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
}
}
@ -213,6 +266,7 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
references: getTagValues(event.tags, 'r'),
id: event.id,
createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
}
}

View file

@ -96,7 +96,13 @@ export interface TicketPurchaseInvoice {
export interface TicketPaymentStatus {
paid: boolean
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases equals the payment_hash. */
ticketId?: string
/** Every row created on this invoice one for single-ticket
* purchases, N for multi-ticket. Each row is independently
* scannable at the door. */
ticketIds?: string[]
}
/**
@ -169,4 +175,6 @@ export interface CreateTicketRequest {
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
}

View file

@ -8,8 +8,9 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -28,14 +29,18 @@ const {
selectedCategories,
hasActiveFilters,
selectedDate,
onlyOwnedTickets,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
resetFilters,
subscribe,
} = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false)
onMounted(() => {
@ -74,6 +79,21 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- "My tickets" filter chip narrows the feed to activities
the user holds at least one paid ticket for. Hidden when
logged out (no tickets to filter on). -->
<div v-if="isAuthenticated" class="mb-4">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
</div>
<!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>

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,55 @@ const categoryLabel = computed(() => {
function goBack() {
router.push({ name: 'activities' })
}
// --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
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 +271,72 @@ 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: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when
capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @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">