Compare commits

..

No commits in common. "ea4e1960f50eef7c639c4d7ddd25ed44b27da5ca" and "663e32e7a477246973ad23118f54399a0bdffa47" have entirely different histories.

8 changed files with 10 additions and 374 deletions

View file

@ -4,10 +4,9 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User, CheckCircle2 } from 'lucide-vue-next' import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue' import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
@ -20,9 +19,6 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.activity const a = props.activity
@ -159,38 +155,19 @@ const placeholderBg = computed(() => {
<span class="truncate">{{ activity.location }}</span> <span class="truncate">{{ activity.location }}</span>
</div> </div>
<!-- Tickets available. `available === undefined` means <!-- Tickets available -->
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 === undefined"> <span v-if="activity.ticketInfo.available > 0">
{{ 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">
{{ t('activities.detail.soldOut') }} {{ t('activities.detail.soldOut') }}
</span> </span>
</div> </div>
<!-- Owned tickets shown when the current user holds at
least one paid ticket for this activity. Sits next to
the availability line so the buyer can see at a glance
whether they've already bought in. -->
<div
v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary"
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -8,7 +8,6 @@ import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity' import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities' import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters' import { useActivityFilters } from './useActivityFilters'
import { useOwnedTickets } from './useOwnedTickets'
/** /**
* Main composable for activities discovery. * Main composable for activities discovery.
@ -18,7 +17,6 @@ export function useActivities() {
const store = useActivitiesStore() const store = useActivitiesStore()
const filters = useActivityFilters() const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false) const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null) const subscriptionError = ref<string | null>(null)
@ -72,10 +70,7 @@ export function useActivities() {
const all = store.activities.sort( const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime() (a, b) => a.startDate.getTime() - b.startDate.getTime()
) )
const filtered = filters.applyFilters(all) return filters.applyFilters(all)
if (!filters.onlyOwnedTickets.value) return filtered
const owned = ownedActivityIds.value
return filtered.filter(a => owned.has(a.id))
}) })
/** /**

View file

@ -15,13 +15,6 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([]) const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined) const selectedDate = ref<Date | undefined>(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<ActivityFilters>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -88,18 +81,12 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
} }
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined || selectedDate.value !== undefined
onlyOwnedTickets.value
) )
return { return {
@ -107,7 +94,6 @@ export function useActivityFilters() {
temporal, temporal,
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -117,7 +103,6 @@ export function useActivityFilters() {
selectDate, selectDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
resetFilters, resetFilters,
} }
} }

View file

@ -1,113 +0,0 @@
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 <ActivityCard>
* + 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<ActivityTicket[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
let hasAutoLoaded = false
let lastLoadedUserId: string | null = null
async function fetchTickets(): Promise<void> {
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<Map<string, ActivityTicket[]>>(() => {
const m = new Map<string, ActivityTicket[]>()
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<Set<string>>(() => {
const s = new Set<string>()
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,
}
}

View file

@ -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, TicketTags } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { TicketedEvent } from './ticket' import type { TicketedEvent } from './ticket'
/** /**
@ -74,26 +74,8 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo { export interface ActivityTicketInfo {
price: number price: number
currency: string currency: string
/** Remaining capacity. Undefined means unlimited. */ available: number
available?: number total: 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,
}
} }
/** /**
@ -122,7 +104,6 @@ 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),
} }
@ -159,7 +140,6 @@ 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),
} }

View file

@ -17,27 +17,6 @@ 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)
*/ */
@ -57,7 +36,6 @@ export interface CalendarDateEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
/** /**
@ -81,7 +59,6 @@ export interface CalendarTimeEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
export interface Participant { export interface Participant {
@ -119,35 +96,6 @@ 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.
@ -218,7 +166,6 @@ 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),
} }
} }
@ -266,7 +213,6 @@ 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),
} }
} }

View file

@ -8,9 +8,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities' import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -29,18 +28,14 @@ const {
selectedCategories, selectedCategories,
hasActiveFilters, hasActiveFilters,
selectedDate, selectedDate,
onlyOwnedTickets,
selectDate, selectDate,
setTemporal, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false) const filtersOpen = ref(false)
onMounted(() => { onMounted(() => {
@ -79,21 +74,6 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div> </div>
<!-- "My tickets" filter chip narrows the feed to activities
the user holds at least one paid ticket for. Hidden when
logged out (no tickets to filter on). -->
<div v-if="isAuthenticated" class="mb-4">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
</div>
<!-- Category filters (collapsible) --> <!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6"> <Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>

View file

@ -8,18 +8,15 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, Calendar, MapPin, ArrowLeft, Pencil,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue' import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities' 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
@ -97,56 +94,6 @@ const categoryLabel = computed(() => {
function goBack() { function goBack() {
router.push({ name: 'activities' }) 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')
}
</script> </script>
<template> <template>
@ -272,67 +219,6 @@ function goToMyTickets() {
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/> />
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). Sections render
bottom-up: existing owned tickets (when count > 0) above
a Purchase CTA (when capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 space-y-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<div class="space-y-1">
<div
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
:key="ticket.id"
class="text-xs font-mono text-muted-foreground break-all"
>
{{ ticket.id }}
</div>
</div>
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
<div v-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0
? t('activities.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
</span>
</Button>
</div>
<p
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('activities.detail.soldOut') }}
</p>
</div>
<PurchaseTicketDialog
v-if="purchaseEvent"
:is-open="showPurchaseDialog"
:event="purchaseEvent"
@update:is-open="showPurchaseDialog = $event"
/>
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">