From 6a35e8e0cbea320a4ed3748a2e02f8dbf6854300 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:39:53 +0200 Subject: [PATCH] feat(activities): parse ticket inventory tags from NIP-52 events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../activities/components/ActivityCard.vue | 9 +++- src/modules/activities/types/activity.ts | 26 +++++++-- src/modules/activities/types/nip52.ts | 54 +++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index d021b10..b93d20a 100644 --- a/src/modules/activities/components/ActivityCard.vue +++ b/src/modules/activities/components/ActivityCard.vue @@ -155,13 +155,18 @@ const placeholderBg = computed(() => { {{ activity.location }} - +
- + + {{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }} + + {{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} diff --git a/src/modules/activities/types/activity.ts b/src/modules/activities/types/activity.ts index a377e8b..993afa7 100644 --- a/src/modules/activities/types/activity.ts +++ b/src/modules/activities/types/activity.ts @@ -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), } diff --git a/src/modules/activities/types/nip52.ts b/src/modules/activities/types/nip52.ts index 332d054..b87db35 100644 --- a/src/modules/activities/types/nip52.ts +++ b/src/modules/activities/types/nip52.ts @@ -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), } }