Compare commits

...

13 commits

Author SHA1 Message Date
cd2b1f2020 ui(activities): center the tickets-remaining line on detail page
Was left-aligned alone on its row above the owned + buy blocks,
which read as visually orphaned. Adding `justify-center` aligns
it with how the line reads as a status pill — same alignment the
buy CTA below uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
4415e01083 ui(activities): surface tickets-remaining on the event detail page
The card had it; the detail page didn't. Reuses the same three-
state language as the card ("Unlimited" / "{count} tickets
available" / "Sold out") so the buyer sees the same signal on
both surfaces.

Placed at the top of the tickets section, above the owned-tickets
chip + buy CTA, so it reads top-down: how many are left → how
many you have → buy more.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
8fcad853ff ui(activities): drop the ticket-id list from the owned-tickets section
The detail page's owned-tickets card was rendering one font-mono
row per ticket id — useful for verifying state during development
but pure noise for the buyer. The "View in My Tickets" button
already links to the place where the buyer interacts with the
individual rows. Collapse to a single line: "You have N tickets"
+ the link button, on one row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
509954e05f fix(activities): simplify purchase success modal + dialog overflow
Three small fixes the buyer flagged on the multi-ticket purchase
flow:

1. Drop the inline QR grid from the success modal. The buyer's
   real ticket interaction lives in My Tickets — the modal's job
   is just to confirm the purchase landed and point them there.
   N stacked QRs made the dialog overflow on small viewports
   (point 2) and duplicated UI that already exists on the
   destination page.
2. DialogContent gets `max-h-[90dvh] overflow-y-auto` so even
   long content (long invoice expiry text, multiple methods, etc.)
   scrolls inside the dialog instead of bleeding off the viewport.
3. Companion to events ext c8602e0 which switched every row to a
   fresh short-hash id (was: first row reused the 64-hex
   payment_hash, rest got short hashes — inconsistent). No webapp
   code change for that — we just consume what the backend
   returns — but worth noting the ids you'll see now are all
   uniform short hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
9c5f55d5f7 feat(activities): one row per attendee + render N QRs on multi-buy
Companion to aiolabs/events PR #15's d087bf3 (N rows sharing one
payment_hash). Now that the backend persists each attendee as a
distinct scannable row, the webapp surfaces them properly:

- TicketPaymentStatus carries `ticketIds: string[]` (every row),
  with `ticketId` kept for back-compat. checkPaymentStatus reads
  both fields off the polling response.
- useTicketPurchase tracks `purchasedTicketIds` + `ticketQRCodes`
  (parallel map id → data url). After payment lands the composable
  generates one QR per row so each attendee has their own.
- PurchaseTicketDialog success screen renders every QR + ticket id
  in a stack with "Ticket N of M" labels. Each can be shared with
  a different attendee for an independent door scan.

Reverts the "seats via extra.quantity" workarounds that landed in
the previous two commits — now that rows == tickets the counters
go back to row-count semantics across MyTickets, ActivityCard
badges, ActivityDetailPage owned-tickets, useUserTickets group
tallies, and the dialog's success header.

Door-scan compatibility: the existing LNbits register-page
scanner (events ext static/js/register.js) already reads
`ticket://<id>` QRs and PUTs /tickets/register/<id>. With N rows
each having a unique uuid id, each attendee's QR maps to a
distinct PUT — independent registration, all 3 friends can enter
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
e3e31e16f5 fix(activities): MyTickets tab pills + group header count seats not rows
Last commit fixed the dialog + ActivityDetailPage to read extra.quantity,
but missed three more row-count → seat-count surfaces in
MyTicketsPage:

- Tab pills (All / Paid / Pending / Registered) used
  `paidTickets.length` etc. on the filtered row arrays — so a user
  who bought 1+5+5+6+3+1+1+1 = 23 seats across 8 rows saw "All
  (8)". Now reads from useUserTickets.{total,paid,pending,
  registered}Seats which sum extra.quantity.
- Group header badge "{{ group.tickets.length }} tickets" → uses
  group.paidCount + pendingCount (already seat-summed by the
  previous fix to groupedTickets).
- Group description gains a "({N} purchases)" sub-line when seats
  ≠ rows so the buyer can see at a glance "you have 23 tickets
  across 8 purchases".
- Per-row carousel card grows a `×N` chip next to the truncated
  Ticket #ID when that row represents multi-seat — same chip
  language as the ActivityDetailPage owned-tickets section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
9a14eaa401 fix(activities): count seats by extra.quantity across all UI surfaces
Earlier commit landed the backend storing N seats on one row via
extra.quantity (one invoice, one payment, one ticket row), but the
UI kept counting rows instead of seats. A 5-ticket purchase
showed:

  Dialog header: "Purchase a ticket for X for 100 sats"  ← lied
  Success modal: "Ticket purchased!" / one ticket ID    ← lied
  My Tickets / badges: "1 paid ticket"                  ← lied

even though the buyer correctly paid 500 sats and 5 seats were
sold (DB verified: extra.quantity=5, sats_paid=500, event.sold
incremented by 5). The bolt11 invoice amount is cryptographic so
the wallet charge was always right — only the labels were wrong.

Fixes:

- ActivityTicketExtra grows `quantity?: number` (the field already
  on the wire from the backend; just adding it to the type).
- useOwnedTickets exposes `seatsOnRow(ticket)` and `paidCount`
  sums seats (extra.quantity) across rows instead of counting
  rows. ActivityCard's "You have N tickets" badge now reflects
  actual seat ownership.
- useUserTickets.groupedTickets sums seats into paidCount /
  pendingCount / registeredCount so MyTicketsPage groups read
  correctly.
- ActivityDetailPage owned-tickets section adds a `×N` chip on
  rows that represent multiple seats so the buyer can see which
  row covers how many.
- PurchaseTicketDialog header + DialogDescription reflect the
  selected quantity ("Purchase 5 tickets" / "5 tickets for X · 500
  sats"). The success modal switches to "5 tickets purchased!" and
  re-labels the ticket id "Purchase ID (covers all tickets)" so
  the buyer doesn't expect 5 separate ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
e861abfcbc feat(activities): multi-ticket purchase + restaurant-style invoice screen
Two related UX changes for the buy flow:

1. Quantity selector in PurchaseTicketDialog (1-10). The total
   line updates as the buyer steps the count up/down; the fiat
   conversion preview reflects the totalled amount. Backend caps
   the upper bound (HTTP 400 if anyone tries to bypass via curl).

2. Restaurant-style invoice screen: when the invoice is generated,
   we drop the "single Pay-with-Wallet button" auto-pay path and
   show the QR + amount + Copy + "Open in wallet" together,
   restaurant OrderInvoiceCard-style. Below that, a "Pay from my
   LNbits wallet" button appears when the buyer is signed in with
   a funded wallet — same screen, two paths, buyer picks at the
   moment they see the invoice. The poll already started fires on
   either path.

useTicketPurchase exposes `payCurrentInvoiceWithWallet()` so the
dialog can trigger the wallet-pay path explicitly without going
through purchaseTicketForEvent again. purchaseTicketForEvent no
longer auto-pays — it just creates the invoice + starts polling.

CreateTicketRequest grows `quantity?` (1..10) and requestTicket
forwards it. Quantity is only sent when > 1 so existing flows
stay byte-identical on the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
41d0d27b6f fix(activities): i18n keys + retry useOwnedTickets after transient failure
i18n: add the missing keys the ticket purchase + owned-tickets
surfaces use across en/es/fr — activities.detail.{buyTicket,
buyAnotherTicket, viewMyTickets, ticketsOwned, unlimitedTickets}
and activities.filters.myTickets. Without these the runtime fell
back to the literal key strings + spammed [intlify] warnings; the
filter chip rendered the bare key text on logged-in sessions.

ticketsOwned uses i18n pluralization so "You have 1 ticket" vs
"You have 5 tickets" both come out correct.

useOwnedTickets: the hasAutoLoaded guard prevented retries after
a transient backend failure (e.g. an LNbits restart mid-fetch).
The composable would stay stuck with tickets = [] forever, so the
buyer landing on a fresh detail page right after a transient error
saw no badges anywhere. Detect the "previous load didn't actually
hydrate" state (lastLoadedUserId still null while authenticated)
and retry on the next useOwnedTickets() call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
f9b5fe886b feat(activities): "My tickets" filter chip on ActivitiesPage
A new filter chip sits below the temporal pills, hidden when the
user is logged out. Clicking it narrows the feed to activities the
user holds at least one paid ticket for — intersecting the
existing filter pipeline (temporal / categories / date) with the
ownedActivityIds set from useOwnedTickets.

The coupling lives in useActivities (it already orchestrates the
data + filter pipeline). useActivityFilters stays free of ticket
fetching; it just carries the boolean state. resetFilters clears
the chip alongside the other filters, and hasActiveFilters lights
up when it's on so the "Clear all" affordance is visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
fc2af7ef71 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>
2026-05-23 21:19:22 +00:00
10f4813d76 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>
2026-05-23 21:19:22 +00:00
97354526a3 feat(activities): parse ticket inventory tags from NIP-52 events
The aiolabs/events extension publishes six AIO custom tags on every
kind 31922/31923 calendar event (tickets_available, _sold, _price,
_currency, _allow_fiat, _fiat_currency — see aiolabs/events#15) and
republishes the event on every ticket sale. Connected clients pick
up the new state via their existing relay subscription, no REST
polling.

- New TicketTags shape on CalendarTimeEvent + CalendarDateEvent.
  parseTicketTags reads the six tags off the raw event; tickets_
  currency is the discriminator so non-AIO calendar events (which
  don't have these tags) cleanly produce undefined.
- ActivityTicketInfo grows `sold` + `allowFiat` + `fiatCurrency`
  for the buyer surfaces, drops the never-populated `total` field,
  makes `available` optional (undefined = unlimited capacity).
- Both calendar→Activity converters now populate ticketInfo via
  ticketTagsToInfo so Nostr-sourced activities carry the inventory
  info that was previously only on LNbits drafts.
- ActivityCard handles the three-state available display
  (unlimited / count / sold-out) instead of just truthy/sold-out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:19:22 +00:00
17 changed files with 698 additions and 95 deletions

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Tomorrow', tomorrow: 'Tomorrow',
thisWeek: 'This Week', thisWeek: 'This Week',
thisMonth: 'This Month', thisMonth: 'This Month',
myTickets: 'My tickets',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', 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', soldOut: 'Sold Out',
free: 'Free', free: 'Free',
}, },

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Mañana', tomorrow: 'Mañana',
thisWeek: 'Esta semana', thisWeek: 'Esta semana',
thisMonth: 'Este mes', thisMonth: 'Este mes',
myTickets: 'Mis boletos',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', 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', soldOut: 'Agotado',
free: 'Gratis', free: 'Gratis',
}, },

View file

@ -57,6 +57,7 @@ const messages: LocaleMessages = {
tomorrow: 'Demain', tomorrow: 'Demain',
thisWeek: 'Cette semaine', thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci', thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -96,6 +97,11 @@ const messages: LocaleMessages = {
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', 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é', soldOut: 'Épuisé',
free: 'Gratuit', free: 'Gratuit',
}, },

View file

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

View file

@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' 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 BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
@ -19,6 +20,9 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.activity const a = props.activity
@ -155,19 +159,38 @@ const placeholderBg = computed(() => {
<span class="truncate">{{ activity.location }}</span> <span class="truncate">{{ activity.location }}</span>
</div> </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 <div
v-if="activity.ticketInfo" v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<Ticket class="w-3.5 h-3.5 shrink-0" /> <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 }) }} {{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }} {{ t('activities.detail.soldOut') }}
</span> </span>
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -7,7 +7,7 @@ import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' 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 { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, { import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry, type PaymentMethod as PaymentMethodEntry,
@ -41,6 +41,7 @@ const {
isLoading, isLoading,
error, error,
paymentHash, paymentHash,
paymentRequest,
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
@ -48,14 +49,39 @@ const {
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,
ticketQRCode, purchasedTicketIds,
purchasedTicketId,
showTicketQR showTicketQR
} = useTicketPurchase() } = 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 { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion() const { convert } = usePriceConversion()
@ -147,10 +173,13 @@ async function handlePurchase() {
const method = selectedMethod.value const method = selectedMethod.value
if (!method) return 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') { if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id) await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
@ -177,7 +206,11 @@ async function handlePurchase() {
props.event.id, props.event.id,
userId, userId,
accessToken, accessToken,
{ paymentMethod: 'fiat', fiatProvider: method.provider }, {
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
) )
if (!invoice.isFiat || !invoice.fiatPaymentRequest) { if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.' fiatError.value = 'Fiat provider did not return a checkout URL.'
@ -206,6 +239,8 @@ function handleClose() {
fiatRedirectUrl.value = null fiatRedirectUrl.value = null
fiatProviderLabel.value = null fiatProviderLabel.value = null
fiatError.value = null fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
} }
onUnmounted(() => { onUnmounted(() => {
@ -215,14 +250,20 @@ onUnmounted(() => {
<template> <template>
<Dialog :open="isOpen" @update:open="handleClose"> <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> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" /> <CreditCard class="w-5 h-5" />
Purchase Ticket {{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} <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> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -297,18 +338,50 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" /> <CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span> <span class="text-sm font-medium">Payment Details:</span>
</div> </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="space-y-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span> <span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span> <span class="text-sm font-medium">{{ event.name }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground">Price:</span> <span class="text-sm text-muted-foreground">
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span> {{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="event.price_per_ticket" :amount="totalPrice"
from="sat" from="sat"
:to="event.fiat_currency" :to="event.fiat_currency"
prefix="Equivalent ~" prefix="Equivalent ~"
@ -363,76 +436,135 @@ onUnmounted(() => {
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)" :disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4" /> <CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout Continue to {{ selectedMethod.label }} checkout
</span> </template>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2"> <template v-else>
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4 mr-2" />
Pay with Wallet {{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</span> </template>
<span v-else>Generate Payment Request</span>
</Button> </Button>
</div> </div>
<!-- Payment QR Code and Status --> <!-- Lightning invoice restaurant-style. Shows QR + amount,
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4"> with both pay paths visible at once: tap-to-pay from the
<div class="text-center space-y-2"> LNbits wallet, scan with an external wallet, or hand off
<h3 class="text-lg font-semibold">Payment Required</h3> via lightning: URI on mobile. Polling fires whichever
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground"> path the buyer takes. -->
Processing payment with your wallet... <div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
</p> <div class="text-center space-y-1">
<p v-else class="text-sm text-muted-foreground"> <h3 class="text-lg font-semibold">Pay the invoice</h3>
Scan the QR code with your Lightning wallet to complete the payment <p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p> </p>
</div> </div>
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4"> <!-- QR + amount + copy/open buttons (restaurant
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" /> OrderInvoiceCard pattern). The QR keeps a white background
</div> regardless of theme so phone cameras parse it reliably. -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="space-y-3 w-full"> <div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full"> <img
<Wallet class="w-4 h-4 mr-2" /> v-if="qrCode"
Open in Lightning Wallet :src="qrCode"
</Button> alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
<div v-if="isPaymentPending" class="text-center space-y-2"> />
<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...' }}
</span>
</div>
<p class="text-xs text-muted-foreground">
Payment will be confirmed automatically once received
</p>
</div> </div>
<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>
<!-- 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">
Waiting for payment
</span>
</div>
<p class="text-xs text-muted-foreground">
Confirmation lands automatically no need to refresh.
</p>
</div> </div>
</div> </div>
<!-- Ticket QR Code (After Successful Purchase) --> <!-- Success state. QRs live in My Tickets no need to
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4"> pre-render them here; this view's job is to confirm the
<div class="text-center space-y-2"> purchase landed and route the buyer to where they actually
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3> interact with their tickets. -->
<p class="text-sm text-muted-foreground"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
Your ticket has been purchased and is now available in your tickets area. <div class="flex justify-center">
</p> <Ticket class="w-12 h-12 text-green-600" />
</div> </div>
<div class="text-center space-y-2">
<div class="bg-muted/50 rounded-lg p-4 w-full"> <h3 class="text-lg font-semibold text-green-600">
<div class="text-center space-y-3"> {{ purchasedTicketIds.length > 1
<div class="flex justify-center"> ? `${purchasedTicketIds.length} tickets purchased!`
<Ticket class="w-12 h-12 text-green-600" /> : 'Ticket purchased!' }}
</div> </h3>
<div> <p class="text-sm text-muted-foreground">
<p class="text-sm font-medium">Ticket ID</p> <span v-if="purchasedTicketIds.length > 1">
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1"> Each attendee gets their own scannable QR in My Tickets
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p> hand them out independently for the door scan.
</div> </span>
</div> <span v-else>
</div> Your ticket is now in My Tickets.
</span>
</p>
</div> </div>
<div class="space-y-3 w-full"> <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 { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities' import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters' import { useActivityFilters } from './useActivityFilters'
import { useOwnedTickets } from './useOwnedTickets'
/** /**
* Main composable for activities discovery. * Main composable for activities discovery.
@ -17,6 +18,7 @@ export function useActivities() {
const store = useActivitiesStore() const store = useActivitiesStore()
const filters = useActivityFilters() const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false) const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null) const subscriptionError = ref<string | null>(null)
@ -70,7 +72,10 @@ export function useActivities() {
const all = store.activities.sort( const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime() (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 temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([]) const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined) 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>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -81,12 +88,18 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
} }
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined selectedDate.value !== undefined ||
onlyOwnedTickets.value
) )
return { return {
@ -94,6 +107,7 @@ export function useActivityFilters() {
temporal, temporal,
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -103,6 +117,7 @@ export function useActivityFilters() {
selectDate, selectDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
resetFilters, 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 qrCode = ref<string | null>(null)
const isPaymentPending = ref(false) 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 ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false) const showTicketQR = ref(false)
// Computed properties // 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) { if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -86,8 +101,11 @@ export function useTicketPurchase() {
paymentRequest.value = null paymentRequest.value = null
qrCode.value = null qrCode.value = null
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService // Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -96,7 +114,8 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket( const invoice = await ticketApi.requestTicket(
eventId, eventId,
currentUser.value!.id, currentUser.value!.id,
accessToken accessToken,
{ quantity: options.quantity },
) )
// Backend now returns either a Lightning invoice or a fiat // Backend now returns either a Lightning invoice or a fiat
@ -119,18 +138,12 @@ export function useTicketPurchase() {
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(bolt11) await generateQRCode(bolt11)
// Try to pay with wallet if available // Restaurant-style: don't auto-pay. Surface the QR + amount and
if (hasWalletWithBalance.value) { // let the buyer pick "Pay with my LNbits wallet" vs "Open in
try { // external wallet" on the same screen. The composable just
await payWithWallet(bolt11) // starts polling so when payment lands (from any path) the UI
await startPaymentStatusCheck(eventId, invoice.paymentHash) // advances to the ticket-QR success state.
} catch (walletError) { await startPaymentStatusCheck(eventId, invoice.paymentHash)
console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
} else {
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
return invoice 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) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true
let checkInterval: number | null = null let checkInterval: number | null = null
@ -152,13 +178,34 @@ export function useTicketPurchase() {
clearInterval(checkInterval) clearInterval(checkInterval)
} }
if (result.ticketId) { // Multi-ticket purchases come back with `ticketIds` (N rows
purchasedTicketId.value = result.ticketId // sharing one invoice). Single-ticket purchases include
await generateTicketQRCode(result.ticketId) // `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 showTicketQR.value = true
} }
toast.success('Ticket purchased successfully!') toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)
@ -180,7 +227,9 @@ export function useTicketPurchase() {
qrCode.value = null qrCode.value = null
isPaymentPending.value = false isPaymentPending.value = false
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
} }
@ -208,7 +257,9 @@ export function useTicketPurchase() {
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
ticketQRCode, ticketQRCode,
ticketQRCodes,
purchasedTicketId, purchasedTicketId,
purchasedTicketIds,
showTicketQR, showTicketQR,
// Computed // Computed
@ -219,6 +270,7 @@ export function useTicketPurchase() {
// Actions // Actions
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,

View file

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

View file

@ -75,6 +75,8 @@ export class TicketApiService {
promoCode?: string promoCode?: string
refundAddress?: string refundAddress?: string
nostrIdentifier?: string nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {}, } = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId } const body: CreateTicketRequest = { user_id: userId }
@ -83,6 +85,7 @@ export class TicketApiService {
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
@ -120,6 +123,7 @@ export class TicketApiService {
return { return {
paid: data.paid === true, paid: data.paid === true,
ticketId: data.ticket_id, 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 ngeohash from 'ngeohash'
import type { ActivityCategory } from './category' import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket' import type { TicketedEvent } from './ticket'
/** /**
@ -74,8 +74,26 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo { export interface ActivityTicketInfo {
price: number price: number
currency: string currency: string
available: number /** Remaining capacity. Undefined means unlimited. */
total: number 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, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} }
@ -140,6 +159,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
geohash: event.geohash, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), 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] 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) * Parsed NIP-52 date-based calendar event (kind 31922)
*/ */
@ -36,6 +57,7 @@ export interface CalendarDateEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
/** /**
@ -59,6 +81,7 @@ export interface CalendarTimeEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
export interface Participant { 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]) 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. * Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings. * 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'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }
@ -213,6 +266,7 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
references: getTagValues(event.tags, 'r'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }

View file

@ -96,7 +96,13 @@ export interface TicketPurchaseInvoice {
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
paid: boolean paid: boolean
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases equals the payment_hash. */
ticketId?: string 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 nostr_identifier?: string
payment_method?: PaymentMethod payment_method?: PaymentMethod
fiat_provider?: string fiat_provider?: string
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
} }

View file

@ -8,8 +8,9 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } 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 { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -28,14 +29,18 @@ const {
selectedCategories, selectedCategories,
hasActiveFilters, hasActiveFilters,
selectedDate, selectedDate,
onlyOwnedTickets,
selectDate, selectDate,
setTemporal, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false) const filtersOpen = ref(false)
onMounted(() => { onMounted(() => {
@ -74,6 +79,21 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div> </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) --> <!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6"> <Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>

View file

@ -8,15 +8,18 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue' import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities' 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
@ -94,6 +97,55 @@ const categoryLabel = computed(() => {
function goBack() { function goBack() {
router.push({ name: 'activities' }) 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> </script>
<template> <template>
@ -219,6 +271,72 @@ function goBack() {
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :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 --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">