From 7cf009cff6ebd0645e72ff005e8267a364665976 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:39:53 +0200 Subject: [PATCH 01/13] 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), } } -- 2.53.0 From fd78a915a665e703575bef648cec52077a01c133 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:42:23 +0200 Subject: [PATCH 02/13] feat(activities): useOwnedTickets composable + ActivityCard ticket badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../activities/components/ActivityCard.vue | 20 +++- .../activities/composables/useOwnedTickets.ts | 113 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/modules/activities/composables/useOwnedTickets.ts diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index b93d20a..d16c376 100644 --- a/src/modules/activities/components/ActivityCard.vue +++ b/src/modules/activities/components/ActivityCard.vue @@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n' import { format } from 'date-fns' import { Card, CardContent } from '@/components/ui/card' 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 { useDateLocale } from '../composables/useDateLocale' +import { useOwnedTickets } from '../composables/useOwnedTickets' import type { Activity } from '../types/activity' const props = defineProps<{ @@ -19,6 +20,9 @@ const emit = defineEmits<{ const { t } = useI18n() const { dateLocale } = useDateLocale() +const { paidCount } = useOwnedTickets() + +const ownedCount = computed(() => paidCount(props.activity.id)) const dateDisplay = computed(() => { const a = props.activity @@ -173,6 +177,20 @@ const placeholderBg = computed(() => { {{ t('activities.detail.soldOut') }}
+ + +
+ + + {{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} + +
diff --git a/src/modules/activities/composables/useOwnedTickets.ts b/src/modules/activities/composables/useOwnedTickets.ts new file mode 100644 index 0000000..5ea5d44 --- /dev/null +++ b/src/modules/activities/composables/useOwnedTickets.ts @@ -0,0 +1,113 @@ +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 + * + 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([]) +const isLoading = ref(false) +const error = ref(null) +let hasAutoLoaded = false +let lastLoadedUserId: string | null = null + +async function fetchTickets(): Promise { + 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>(() => { + const m = new Map() + 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>(() => { + const s = new Set() + 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) ?? [] +} + +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. Subsequent calls just + // 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() + }, + ) + } + + return { + tickets, + ticketsByActivity, + ownedActivityIds, + getTickets, + paidCount, + refresh: fetchTickets, + isLoading, + error, + isAuthenticated, + } +} -- 2.53.0 From 5589bb3e67c76a0e0113a203e6d93a7dd601c6c1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:44:15 +0200 Subject: [PATCH 03/13] feat(activities): purchase + owned-tickets section on ActivityDetailPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../activities/views/ActivityDetailPage.vue | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 1929ca2..c347363 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -8,15 +8,18 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { - Calendar, MapPin, ArrowLeft, Pencil, + Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, } from 'lucide-vue-next' import { useActivityDetail } from '../composables/useActivityDetail' import BookmarkButton from '../components/BookmarkButton.vue' import RSVPButton from '../components/RSVPButton.vue' import OrganizerCard from '../components/OrganizerCard.vue' +import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' import { NIP52_KINDS } from '../types/nip52' import { useAuth } from '@/composables/useAuthService' 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 type { TicketApiService } from '../services/TicketApiService' import type { TicketedEvent } from '../types/ticket' @@ -94,6 +97,56 @@ const categoryLabel = computed(() => { function goBack() { router.push({ name: 'activities' }) } + +// --- Ticket purchase + owned-tickets surface ---------------------- + +const { getTickets, paidCount, refresh: refreshOwnedTickets } = useOwnedTickets() + +const ownedTicketsForActivity = computed(() => getTickets(activityId)) +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') +}