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>
This commit is contained in:
Padreug 2026-05-23 20:39:53 +02:00
commit 6a35e8e0cb
3 changed files with 84 additions and 5 deletions

View file

@ -155,13 +155,18 @@ 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">

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),
}
}