Compare commits
4 commits
663e32e7a4
...
ea4e1960f5
| Author | SHA1 | Date | |
|---|---|---|---|
| ea4e1960f5 | |||
| 5589bb3e67 | |||
| fd78a915a6 | |||
| 7cf009cff6 |
8 changed files with 374 additions and 10 deletions
|
|
@ -4,9 +4,10 @@ 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 } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User, CheckCircle2 } 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<{
|
||||||
|
|
@ -19,6 +20,9 @@ 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
|
||||||
|
|
@ -155,19 +159,38 @@ 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">
|
||||||
{{ 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>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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.
|
||||||
|
|
@ -17,6 +18,7 @@ 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)
|
||||||
|
|
@ -70,7 +72,10 @@ 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()
|
||||||
)
|
)
|
||||||
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))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ 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,
|
||||||
|
|
@ -81,12 +88,18 @@ 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 {
|
||||||
|
|
@ -94,6 +107,7 @@ export function useActivityFilters() {
|
||||||
temporal,
|
temporal,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
onlyOwnedTickets,
|
||||||
filters,
|
filters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
|
||||||
|
|
@ -103,6 +117,7 @@ export function useActivityFilters() {
|
||||||
selectDate,
|
selectDate,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
|
toggleOwnedTickets,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
src/modules/activities/composables/useOwnedTickets.ts
Normal file
113
src/modules/activities/composables/useOwnedTickets.ts
Normal file
|
|
@ -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 <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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} 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 { 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'
|
||||||
|
|
@ -28,14 +29,18 @@ 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(() => {
|
||||||
|
|
@ -74,6 +79,21 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,18 @@ 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,
|
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
|
||||||
} 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'
|
||||||
|
|
@ -94,6 +97,56 @@ 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>
|
||||||
|
|
@ -219,6 +272,67 @@ function goBack() {
|
||||||
: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 calendar→Activity 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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue