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