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:
parent
663e32e7a4
commit
7cf009cff6
3 changed files with 84 additions and 5 deletions
|
|
@ -155,13 +155,18 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue