diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index d021b10..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 @@ -155,19 +159,38 @@ const placeholderBg = computed(() => { {{ activity.location }} - +
- + + {{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }} + + {{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} {{ t('activities.detail.soldOut') }}
+ + +
+ + + {{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} + +
diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts index 78b1bb5..67b49b5 100644 --- a/src/modules/activities/composables/useActivities.ts +++ b/src/modules/activities/composables/useActivities.ts @@ -8,6 +8,7 @@ import type { TicketedEvent } from '../types/ticket' import { ticketedEventToActivity } from '../types/activity' import { useActivitiesStore } from '../stores/activities' import { useActivityFilters } from './useActivityFilters' +import { useOwnedTickets } from './useOwnedTickets' /** * Main composable for activities discovery. @@ -17,6 +18,7 @@ export function useActivities() { const store = useActivitiesStore() const filters = useActivityFilters() const { isAuthenticated, currentUser } = useAuth() + const { ownedActivityIds } = useOwnedTickets() const isSubscribed = ref(false) const subscriptionError = ref(null) @@ -70,7 +72,10 @@ export function useActivities() { const all = store.activities.sort( (a, b) => a.startDate.getTime() - b.startDate.getTime() ) - return filters.applyFilters(all) + const filtered = filters.applyFilters(all) + if (!filters.onlyOwnedTickets.value) return filtered + const owned = ownedActivityIds.value + return filtered.filter(a => owned.has(a.id)) }) /** diff --git a/src/modules/activities/composables/useActivityFilters.ts b/src/modules/activities/composables/useActivityFilters.ts index ab3624b..60bcb5e 100644 --- a/src/modules/activities/composables/useActivityFilters.ts +++ b/src/modules/activities/composables/useActivityFilters.ts @@ -15,6 +15,13 @@ export function useActivityFilters() { const temporal = ref(DEFAULT_FILTERS.temporal) const selectedCategories = ref([]) const selectedDate = ref(undefined) + /** + * When true, the feed is narrowed to activities the current user + * holds at least one paid ticket for. Crossed with the + * `ownedActivityIds` set from useOwnedTickets in useActivities + * (this composable stays free of ticket fetching). + */ + const onlyOwnedTickets = ref(false) const filters = computed(() => ({ temporal: temporal.value, @@ -81,12 +88,18 @@ export function useActivityFilters() { temporal.value = DEFAULT_FILTERS.temporal selectedCategories.value = [] selectedDate.value = undefined + onlyOwnedTickets.value = false + } + + function toggleOwnedTickets() { + onlyOwnedTickets.value = !onlyOwnedTickets.value } const hasActiveFilters = computed(() => temporal.value !== 'all' || selectedCategories.value.length > 0 || - selectedDate.value !== undefined + selectedDate.value !== undefined || + onlyOwnedTickets.value ) return { @@ -94,6 +107,7 @@ export function useActivityFilters() { temporal, selectedCategories, selectedDate, + onlyOwnedTickets, filters, hasActiveFilters, @@ -103,6 +117,7 @@ export function useActivityFilters() { selectDate, toggleCategory, clearCategories, + toggleOwnedTickets, resetFilters, } } 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, + } +} 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), } } diff --git a/src/modules/activities/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue index 684aa97..d685a11 100644 --- a/src/modules/activities/views/ActivitiesPage.vue +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -8,8 +8,9 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' -import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next' +import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next' import { useActivities } from '../composables/useActivities' +import { useAuth } from '@/composables/useAuthService' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue' @@ -28,14 +29,18 @@ const { selectedCategories, hasActiveFilters, selectedDate, + onlyOwnedTickets, selectDate, setTemporal, toggleCategory, clearCategories, + toggleOwnedTickets, resetFilters, subscribe, } = useActivities() +const { isAuthenticated } = useAuth() + const filtersOpen = ref(false) onMounted(() => { @@ -74,6 +79,21 @@ function handleSelectActivity(activity: Activity) { + +
+ +
+ 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') +}