Consolidate events ticketing into activities module

Absorb the disabled events module into the active activities module,
eliminating duplicated LNbits events extension API surface and legacy
imports. Activities now owns all ticketing UI (EventsPage, MyTicketsPage
with QR codes, PurchaseTicketDialog, CreateEventDialog) alongside its
existing Nostr NIP-52 calendar event discovery.

- Internalize payInvoiceWithWallet in PaymentService (core LNbits endpoint)
- Enhance TicketApiService with createEvent and getCurrencies methods
- Add TICKET_API DI token for canonical ticket service access
- Port composables (useTicketPurchase, useUserTickets, useEvents) with DI
- Port components (PurchaseTicketDialog, CreateEventDialog)
- Replace MyTicketsPage placeholder with full QR-code ticket display
- Add /events route to activities module
- Delete src/modules/events/, src/lib/api/events.ts, src/lib/types/event.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-26 22:26:28 +02:00
commit e61d3c4d46
21 changed files with 547 additions and 1480 deletions

View file

@ -88,19 +88,6 @@ export const appConfig: AppConfig = {
} }
} }
}, },
events: {
name: 'events',
enabled: false,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
apiKey: import.meta.env.VITE_API_KEY || ''
},
ticketValidationEndpoint: '/api/tickets/validate',
maxTicketsPerUser: 10
}
},
activities: { activities: {
name: 'activities', name: 'activities',
enabled: true, enabled: true,

View file

@ -13,7 +13,6 @@ import appConfig from './app.config'
import baseModule from './modules/base' import baseModule from './modules/base'
import nostrFeedModule from './modules/nostr-feed' import nostrFeedModule from './modules/nostr-feed'
import chatModule from './modules/chat' import chatModule from './modules/chat'
import eventsModule from './modules/events'
import marketModule from './modules/market' import marketModule from './modules/market'
import walletModule from './modules/wallet' import walletModule from './modules/wallet'
import expensesModule from './modules/expenses' import expensesModule from './modules/expenses'
@ -44,7 +43,6 @@ export async function createAppInstance() {
...baseModule.routes || [], ...baseModule.routes || [],
...nostrFeedModule.routes || [], ...nostrFeedModule.routes || [],
...chatModule.routes || [], ...chatModule.routes || [],
...eventsModule.routes || [],
...marketModule.routes || [], ...marketModule.routes || [],
...walletModule.routes || [], ...walletModule.routes || [],
...expensesModule.routes || [], ...expensesModule.routes || [],
@ -113,13 +111,6 @@ export async function createAppInstance() {
) )
} }
// Register events module
if (appConfig.modules.events.enabled) {
moduleRegistrations.push(
pluginManager.register(eventsModule, appConfig.modules.events)
)
}
// Register market module // Register market module
if (appConfig.modules.market.enabled) { if (appConfig.modules.market.enabled) {
moduleRegistrations.push( moduleRegistrations.push(

View file

@ -26,11 +26,11 @@ export function useModularNavigation() {
items.push({ name: t('nav.home'), href: '/', requiresAuth: true }) items.push({ name: t('nav.home'), href: '/', requiresAuth: true })
// Add navigation items based on enabled modules // Add navigation items based on enabled modules
if (appConfig.modules.events.enabled) { if (appConfig.modules.activities?.enabled) {
items.push({ items.push({
name: t('nav.events'), name: t('nav.events'),
href: '/events', href: '/events',
requiresAuth: true requiresAuth: true
}) })
} }
@ -67,8 +67,8 @@ export function useModularNavigation() {
const userMenuItems = computed<NavigationItem[]>(() => { const userMenuItems = computed<NavigationItem[]>(() => {
const items: NavigationItem[] = [] const items: NavigationItem[] = []
// Events module items // Activities module items (events + tickets)
if (appConfig.modules.events.enabled) { if (appConfig.modules.activities?.enabled) {
items.push({ items.push({
name: 'My Tickets', name: 'My Tickets',
href: '/my-tickets', href: '/my-tickets',

View file

@ -149,12 +149,10 @@ export const SERVICE_TOKENS = {
// Nostr metadata services // Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
// Events services // Activities services (Nostr-native events + ticketing module)
EVENTS_SERVICE: Symbol('eventsService'),
// Activities services (Nostr-native events module)
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'), ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'), ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
TICKET_API: Symbol('ticketApi'),
// Invoice services // Invoice services
INVOICE_SERVICE: Symbol('invoiceService'), INVOICE_SERVICE: Symbol('invoiceService'),

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { payInvoiceWithWallet } from '@/lib/api/events' import { config } from '@/lib/config'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
export interface PaymentResult { export interface PaymentResult {
@ -198,6 +198,41 @@ export class PaymentService extends BaseService {
} }
} }
/**
* Pay a Lightning invoice via LNbits POST /api/v1/payments.
* This is a core LNbits operation, not extension-specific.
*/
private async payInvoice(
paymentRequest: string,
adminKey: string
): Promise<PaymentResult> {
const baseUrl = config.api.baseUrl
const response = await fetch(`${baseUrl}/api/v1/payments`, {
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify({
out: true,
bolt11: paymentRequest,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Payment failed' }))
const errorMessage = typeof error.detail === 'string'
? error.detail
: Array.isArray(error.detail)
? error.detail[0]?.msg ?? 'Payment failed'
: 'Payment failed'
throw new Error(errorMessage)
}
return await response.json()
}
/** /**
* Pay Lightning invoice with user's wallet * Pay Lightning invoice with user's wallet
*/ */
@ -224,9 +259,8 @@ export class PaymentService extends BaseService {
this.debug(`Paying invoice with wallet: ${wallet.id.slice(0, 8)}`) this.debug(`Paying invoice with wallet: ${wallet.id.slice(0, 8)}`)
// Make payment // Make payment
const paymentResult = await payInvoiceWithWallet( const paymentResult = await this.payInvoice(
paymentRequest, paymentRequest,
wallet.id,
wallet.adminkey wallet.adminkey
) )

View file

@ -1,169 +0,0 @@
import type { Event, Ticket } from '../types/event'
import { config } from '@/lib/config'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LnbitsAPI } from './lnbits'
const API_BASE_URL = config.api.baseUrl || 'http://lnbits'
const API_KEY = config.api.key
// Generic error type for API responses
interface ApiError {
detail: string | Array<{ loc: [string, number]; msg: string; type: string }>
}
export async function fetchEvents(): Promise<Event[]> {
try {
// Use the new public endpoint that allows access to all events without authentication
const response = await fetch(
`${API_BASE_URL}/events/api/v1/events/public`,
{
headers: {
'accept': 'application/json',
},
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch events'
throw new Error(errorMessage)
}
return await response.json() as Event[]
} catch (error) {
console.error('Error fetching events:', error)
throw error
}
}
export async function purchaseTicket(eventId: string): Promise<{ payment_hash: string; payment_request: string }> {
try {
// Get injected LnbitsAPI service
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as LnbitsAPI
// Get current user to ensure authentication
const user = await lnbitsAPI.getCurrentUser()
if (!user) {
throw new Error('User not authenticated')
}
const response = await fetch(
`${API_BASE_URL}/events/api/v1/tickets/${eventId}/user/${user.id}`,
{
method: 'GET',
headers: {
'accept': 'application/json',
'X-API-KEY': API_KEY,
'Authorization': `Bearer ${lnbitsAPI.getAccessToken()}`,
},
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to purchase ticket'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error purchasing ticket:', error)
throw error
}
}
export async function payInvoiceWithWallet(paymentRequest: string, _walletId: string, adminKey: string): Promise<{ payment_hash: string; fee_msat: number; preimage: string }> {
try {
const response = await fetch(
`${API_BASE_URL}/api/v1/payments`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify({
out: true,
bolt11: paymentRequest,
}),
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to pay invoice'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error paying invoice:', error)
throw error
}
}
export async function checkPaymentStatus(eventId: string, paymentHash: string): Promise<{ paid: boolean; ticket_id?: string }> {
try {
const response = await fetch(
`${API_BASE_URL}/events/api/v1/tickets/${eventId}/${paymentHash}`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'X-API-KEY': API_KEY,
},
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to check payment status'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error checking payment status:', error)
throw error
}
}
export async function fetchUserTickets(userId: string): Promise<Ticket[]> {
try {
// Get injected LnbitsAPI service
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as LnbitsAPI
const response = await fetch(
`${API_BASE_URL}/events/api/v1/tickets/user/${userId}`,
{
headers: {
'accept': 'application/json',
'X-API-KEY': API_KEY,
'Authorization': `Bearer ${lnbitsAPI.getAccessToken()}`,
},
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch user tickets'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error fetching user tickets:', error)
throw error
}
}

View file

@ -1,36 +0,0 @@
export interface Event {
id: string
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
}
export interface Ticket {
id: string
wallet: string
event: string
name: string | null
email: string | null
user_id: string | null
registered: boolean
paid: boolean
time: string
reg_timestamp: string
}
export interface EventsApiError {
detail: Array<{
loc: [string, number]
msg: string
type: string
}>
}

View file

@ -32,10 +32,9 @@ import {
import { Calendar, Loader2 } from 'lucide-vue-next' import { Calendar, Loader2 } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService' import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { EVENTS_API_TOKEN } from '../composables/useEvents' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/event' import type { CreateEventRequest } from '../types/ticket'
// Props
interface Props { interface Props {
open: boolean open: boolean
onCreateEvent: (eventData: CreateEventRequest) => Promise<void> onCreateEvent: (eventData: CreateEventRequest) => Promise<void>
@ -47,8 +46,6 @@ const emit = defineEmits<{
'event-created': [] 'event-created': []
}>() }>()
// Form validation schema (removed wallet field - will be auto-selected)
// Note: Ticket sales will automatically close when the event ends
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(z.object({
name: z.string().min(1, "Event name is required").max(200, "Name too long"), name: z.string().min(1, "Event name is required").max(200, "Name too long"),
info: z.string().min(1, "Event description is required").max(2000, "Description too long"), info: z.string().min(1, "Event description is required").max(2000, "Description too long"),
@ -67,7 +64,6 @@ const formSchema = toTypedSchema(z.object({
path: ["event_end_date"] path: ["event_end_date"]
})) }))
// Form setup
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
@ -82,29 +78,19 @@ const form = useForm({
} }
}) })
// Get PaymentService for wallet selection
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
// Get EventsApiService for currency loading
const eventsApi = injectService(EVENTS_API_TOKEN)
// Load available currencies
const availableCurrencies = ref<string[]>(['sats']) const availableCurrencies = ref<string[]>(['sats'])
const loadingCurrencies = ref(false) const loadingCurrencies = ref(false)
// Load currencies when dialog opens
watch(() => props.open, async (isOpen) => { watch(() => props.open, async (isOpen) => {
if (isOpen && eventsApi && !loadingCurrencies.value) { if (isOpen && ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true loadingCurrencies.value = true
try { try {
// Type cast to ensure getCurrencies method exists availableCurrencies.value = await ticketApi.getCurrencies()
const apiService = eventsApi as any
if (apiService.getCurrencies) {
availableCurrencies.value = await apiService.getCurrencies()
}
} catch (error) { } catch (error) {
console.warn('Failed to load currencies:', error) console.warn('Failed to load currencies:', error)
// Keep default currencies
} finally { } finally {
loadingCurrencies.value = false loadingCurrencies.value = false
} }
@ -115,35 +101,31 @@ const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid) const isFormValid = computed(() => meta.value.valid)
const isLoading = ref(false) const isLoading = ref(false)
// Get today's date in YYYY-MM-DD format for min date validation
const today = computed(() => format(new Date(), 'yyyy-MM-dd')) const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
// Form submission
const onSubmit = form.handleSubmit(async (formValues) => { const onSubmit = form.handleSubmit(async (formValues) => {
if (!isFormValid.value) return if (!isFormValid.value) return
if (!paymentService) { if (!paymentService) {
toastService.error('Payment service not available') toastService.error('Payment service not available')
return return
} }
// Type cast to ensure getPreferredWallet method exists
const paymentSvc = paymentService as any const paymentSvc = paymentService as any
const preferredWallet = paymentSvc?.getPreferredWallet?.() const preferredWallet = paymentSvc?.getPreferredWallet?.()
if (!preferredWallet) { if (!preferredWallet) {
toastService.error('No wallet available. Please connect a wallet first.') toastService.error('No wallet available. Please connect a wallet first.')
return return
} }
isLoading.value = true isLoading.value = true
try { try {
// Add the selected wallet ID and set closing_date to event_end_date
const eventData: CreateEventRequest = { const eventData: CreateEventRequest = {
...formValues, ...formValues,
wallet: preferredWallet.id, wallet: preferredWallet.id,
closing_date: formValues.event_end_date // Ticket sales close when event ends closing_date: formValues.event_end_date
} }
await props.onCreateEvent(eventData) await props.onCreateEvent(eventData)
toastService.success('Event created successfully!') toastService.success('Event created successfully!')
resetForm() resetForm()
@ -157,7 +139,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
} }
}) })
// Handle dialog close
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
resetForm() resetForm()
@ -180,47 +161,33 @@ const handleOpenChange = (open: boolean) => {
</DialogHeader> </DialogHeader>
<form @submit="onSubmit" class="space-y-6 mt-4"> <form @submit="onSubmit" class="space-y-6 mt-4">
<!-- Event Name -->
<FormField v-slot="{ componentField }" name="name"> <FormField v-slot="{ componentField }" name="name">
<FormItem> <FormItem>
<FormLabel>Event Name *</FormLabel> <FormLabel>Event Name *</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="Enter event name" v-bind="componentField" />
placeholder="Enter event name"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Event Description -->
<FormField v-slot="{ componentField }" name="info"> <FormField v-slot="{ componentField }" name="info">
<FormItem> <FormItem>
<FormLabel>Event Description *</FormLabel> <FormLabel>Event Description *</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea placeholder="Describe your event..." rows="3" v-bind="componentField" />
placeholder="Describe your event..."
rows="3"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormDescription>Provide details about your event</FormDescription> <FormDescription>Provide details about your event</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Date Fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="event_start_date"> <FormField v-slot="{ componentField }" name="event_start_date">
<FormItem> <FormItem>
<FormLabel>Event Starts *</FormLabel> <FormLabel>Event Starts *</FormLabel>
<FormControl> <FormControl>
<Input <Input type="date" :min="today" v-bind="componentField" />
type="date"
:min="today"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -230,30 +197,19 @@ const handleOpenChange = (open: boolean) => {
<FormItem> <FormItem>
<FormLabel>Event Ends *</FormLabel> <FormLabel>Event Ends *</FormLabel>
<FormControl> <FormControl>
<Input <Input type="date" :min="today" v-bind="componentField" />
type="date"
:min="today"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
<!-- Ticket Details -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem>
<FormLabel>Total Tickets *</FormLabel> <FormLabel>Total Tickets *</FormLabel>
<FormControl> <FormControl>
<Input <Input type="number" min="1" max="100000" placeholder="100" v-bind="componentField" />
type="number"
min="1"
max="100000"
placeholder="100"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -263,13 +219,7 @@ const handleOpenChange = (open: boolean) => {
<FormItem> <FormItem>
<FormLabel>Price per Ticket *</FormLabel> <FormLabel>Price per Ticket *</FormLabel>
<FormControl> <FormControl>
<Input <Input type="number" min="0" step="0.01" placeholder="1000" v-bind="componentField" />
type="number"
min="0"
step="0.01"
placeholder="1000"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -284,11 +234,7 @@ const handleOpenChange = (open: boolean) => {
<SelectValue placeholder="Select currency" /> <SelectValue placeholder="Select currency" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
{{ currency }} {{ currency }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -303,36 +249,22 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
</div> </div>
<!-- Banner URL (Optional) -->
<FormField v-slot="{ componentField }" name="banner"> <FormField v-slot="{ componentField }" name="banner">
<FormItem> <FormItem>
<FormLabel>Banner Image URL (Optional)</FormLabel> <FormLabel>Banner Image URL (Optional)</FormLabel>
<FormControl> <FormControl>
<Input <Input type="url" placeholder="https://example.com/banner.jpg" v-bind="componentField" />
type="url"
placeholder="https://example.com/banner.jpg"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormDescription>URL to an image for your event banner</FormDescription> <FormDescription>URL to an image for your event banner</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<Button <Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
type="button"
variant="outline"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" :disabled="isLoading || !isFormValid">
type="submit"
:disabled="isLoading || !isFormValid"
>
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ isLoading ? 'Creating...' : 'Create Event' }} {{ isLoading ? 'Creating...' : 'Create Event' }}
</Button> </Button>

View file

@ -1,4 +1,3 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
@ -31,7 +30,6 @@ const {
isLoading, isLoading,
error, error,
paymentHash, paymentHash,
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
@ -62,7 +60,6 @@ function handleClose() {
resetPaymentState() resetPaymentState()
} }
// Cleanup on unmount
onUnmounted(() => { onUnmounted(() => {
cleanup() cleanup()
}) })
@ -168,8 +165,8 @@ onUnmounted(() => {
{{ error }} {{ error }}
</div> </div>
<Button <Button
@click="handlePurchase" @click="handlePurchase"
:disabled="isLoading || !canPurchase" :disabled="isLoading || !canPurchase"
class="w-full" class="w-full"
> >
@ -203,7 +200,7 @@ onUnmounted(() => {
<Wallet class="w-4 h-4 mr-2" /> <Wallet class="w-4 h-4 mr-2" />
Open in Lightning Wallet Open in Lightning Wallet
</Button> </Button>
<div v-if="isPaymentPending" class="text-center space-y-2"> <div v-if="isPaymentPending" class="text-center space-y-2">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div> <div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
@ -252,4 +249,4 @@ onUnmounted(() => {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>

View file

@ -1,22 +1,15 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from '@vueuse/core'
import { injectService } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { Event } from '../types/event' import type { TicketApiService } from '../services/TicketApiService'
import type { EventsApiService } from '../services/events-api' import type { TicketedEvent } from '../types/ticket'
// Service token for events API
export const EVENTS_API_TOKEN = Symbol('eventsApi')
export function useEvents() { export function useEvents() {
const eventsApi = injectService<EventsApiService>(EVENTS_API_TOKEN) const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
if (!eventsApi) {
throw new Error('EventsApiService not available. Make sure events module is installed.')
}
const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState( const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
() => eventsApi.fetchEvents(), () => ticketApi.fetchTicketedEvents() as Promise<TicketedEvent[]>,
[] as Event[], [] as TicketedEvent[],
{ {
immediate: true, immediate: true,
resetOnExecute: false, resetOnExecute: false,
@ -62,4 +55,4 @@ export function useEvents() {
error, error,
refresh, refresh,
} }
} }

View file

@ -1,29 +1,29 @@
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { purchaseTicket, checkPaymentStatus } from '@/lib/api/events'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService'
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation()
// State // State
const paymentHash = ref<string | null>(null) const paymentHash = ref<string | null>(null)
const paymentRequest = ref<string | null>(null) const paymentRequest = ref<string | null>(null)
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false) const isPaymentPending = ref(false)
// Ticket QR code state // Ticket QR code state
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null)
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null)
const showTicketQR = ref(false) const showTicketQR = ref(false)
// Computed properties // Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value) const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
@ -34,23 +34,20 @@ export function useTicketPurchase() {
shortId: currentUser.value.id.slice(0, 8) shortId: currentUser.value.id.slice(0, 8)
} }
}) })
// Delegate to PaymentService // Delegate to PaymentService
const userWallets = computed(() => paymentService.userWallets) const userWallets = computed(() => paymentService.userWallets)
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance) const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance)
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value) const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value)
// Generate QR code for Lightning payment - delegate to PaymentService
async function generateQRCode(bolt11: string) { async function generateQRCode(bolt11: string) {
try { try {
qrCode.value = await paymentService.generateQRCode(bolt11) qrCode.value = await paymentService.generateQRCode(bolt11)
} catch (err) { } catch (err) {
console.error('Error generating QR code:', err) console.error('Error generating QR code:', err)
// Note: error handling is now managed by the purchaseOperation
} }
} }
// Generate QR code for ticket - delegate to PaymentService
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const ticketUrl = `ticket://${ticketId}` const ticketUrl = `ticket://${ticketId}`
@ -66,11 +63,10 @@ export function useTicketPurchase() {
} }
} }
// Pay with wallet - delegate to PaymentService async function payWithWallet(pr: string) {
async function payWithWallet(paymentRequest: string) {
try { try {
await paymentService.payWithWallet(paymentRequest, undefined, { await paymentService.payWithWallet(pr, undefined, {
showToast: false // We'll handle success notifications in the ticket purchase flow showToast: false
}) })
return true return true
} catch (error) { } catch (error) {
@ -79,9 +75,8 @@ export function useTicketPurchase() {
} }
} }
// Purchase ticket for event
async function purchaseTicketForEvent(eventId: string) { async function purchaseTicketForEvent(eventId: string) {
if (!canPurchase.value) { if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -94,58 +89,60 @@ export function useTicketPurchase() {
purchasedTicketId.value = null purchasedTicketId.value = null
showTicketQR.value = false showTicketQR.value = false
// Get the invoice // Get the invoice via TicketApiService
const invoice = await purchaseTicket(eventId) const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
paymentHash.value = invoice.payment_hash const accessToken = lnbitsAPI?.getAccessToken?.() || ''
paymentRequest.value = invoice.payment_request
const invoice = await ticketApi.requestTicket(
eventId,
currentUser.value!.id,
accessToken
)
paymentHash.value = invoice.paymentHash
paymentRequest.value = invoice.paymentRequest
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(invoice.payment_request) await generateQRCode(invoice.paymentRequest)
// Try to pay with wallet if available // Try to pay with wallet if available
if (hasWalletWithBalance.value) { if (hasWalletWithBalance.value) {
try { try {
await payWithWallet(invoice.payment_request) await payWithWallet(invoice.paymentRequest)
// If wallet payment succeeds, proceed to check payment status await startPaymentStatusCheck(eventId, invoice.paymentHash)
await startPaymentStatusCheck(eventId, invoice.payment_hash)
} catch (walletError) { } catch (walletError) {
// If wallet payment fails, fall back to manual payment
console.log('Wallet payment failed, falling back to manual payment:', walletError) console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.payment_hash) await startPaymentStatusCheck(eventId, invoice.paymentHash)
} }
} else { } else {
// No wallet balance, proceed with manual payment await startPaymentStatusCheck(eventId, invoice.paymentHash)
await startPaymentStatusCheck(eventId, invoice.payment_hash)
} }
return invoice return invoice
}, { }, {
errorMessage: 'Failed to purchase ticket' errorMessage: 'Failed to purchase ticket'
}) })
} }
// Start payment status check
async function startPaymentStatusCheck(eventId: string, hash: string) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true
let checkInterval: number | null = null let checkInterval: number | null = null
const checkPayment = async () => { const checkPayment = async () => {
try { try {
const result = await checkPaymentStatus(eventId, hash) const result = await ticketApi.checkPaymentStatus(eventId, hash)
if (result.paid) { if (result.paid) {
isPaymentPending.value = false isPaymentPending.value = false
if (checkInterval) { if (checkInterval) {
clearInterval(checkInterval) clearInterval(checkInterval)
} }
// Generate ticket QR code if (result.ticketId) {
if (result.ticket_id) { purchasedTicketId.value = result.ticketId
purchasedTicketId.value = result.ticket_id await generateTicketQRCode(result.ticketId)
await generateTicketQRCode(result.ticket_id)
showTicketQR.value = true showTicketQR.value = true
} }
toast.success('Ticket purchased successfully!') toast.success('Ticket purchased successfully!')
} }
} catch (err) { } catch (err) {
@ -153,19 +150,14 @@ export function useTicketPurchase() {
} }
} }
// Check immediately
await checkPayment() await checkPayment()
// Then check every 2 seconds
checkInterval = setInterval(checkPayment, 2000) as unknown as number checkInterval = setInterval(checkPayment, 2000) as unknown as number
} }
// Stop payment status check
function stopPaymentStatusCheck() { function stopPaymentStatusCheck() {
isPaymentPending.value = false isPaymentPending.value = false
} }
// Reset payment state
function resetPaymentState() { function resetPaymentState() {
purchaseOperation.clear() purchaseOperation.clear()
paymentHash.value = null paymentHash.value = null
@ -177,19 +169,16 @@ export function useTicketPurchase() {
showTicketQR.value = false showTicketQR.value = false
} }
// Open Lightning wallet - delegate to PaymentService
function handleOpenLightningWallet() { function handleOpenLightningWallet() {
if (paymentRequest.value) { if (paymentRequest.value) {
paymentService.openExternalWallet(paymentRequest.value) paymentService.openExternalWallet(paymentRequest.value)
} }
} }
// Cleanup function
function cleanup() { function cleanup() {
stopPaymentStatusCheck() stopPaymentStatusCheck()
} }
// Lifecycle
onUnmounted(() => { onUnmounted(() => {
cleanup() cleanup()
}) })
@ -206,13 +195,13 @@ export function useTicketPurchase() {
ticketQRCode, ticketQRCode,
purchasedTicketId, purchasedTicketId,
showTicketQR, showTicketQR,
// Computed // Computed
canPurchase, canPurchase,
userDisplay, userDisplay,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
// Actions // Actions
purchaseTicketForEvent, purchaseTicketForEvent,
handleOpenLightningWallet, handleOpenLightningWallet,
@ -220,4 +209,4 @@ export function useTicketPurchase() {
cleanup, cleanup,
generateTicketQRCode generateTicketQRCode
} }
} }

View file

@ -1,12 +1,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from '@vueuse/core'
import type { Ticket } from '@/lib/types/event'
import { fetchUserTickets } from '@/lib/api/events'
import { useAuth } from '@/composables/useAuthService' 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'
interface GroupedTickets { interface GroupedTickets {
eventId: string eventId: string
tickets: Ticket[] tickets: ActivityTicket[]
paidCount: number paidCount: number
pendingCount: number pendingCount: number
registeredCount: number registeredCount: number
@ -14,15 +15,18 @@ interface GroupedTickets {
export function useUserTickets() { export function useUserTickets() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState( const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState(
async () => { async () => {
if (!isAuthenticated.value || !currentUser.value) { if (!isAuthenticated.value || !currentUser.value) {
return [] return []
} }
return await fetchUserTickets(currentUser.value.id) const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
return await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
}, },
[] as Ticket[], [] as ActivityTicket[],
{ {
immediate: false, immediate: false,
resetOnExecute: false, resetOnExecute: false,
@ -62,36 +66,35 @@ export function useUserTickets() {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered) return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
}) })
// Group tickets by event
const groupedTickets = computed(() => { const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>() const groups = new Map<string, GroupedTickets>()
sortedTickets.value.forEach(ticket => { sortedTickets.value.forEach(ticket => {
if (!groups.has(ticket.event)) { const eventKey = ticket.activityId
groups.set(ticket.event, { if (!groups.has(eventKey)) {
eventId: ticket.event, groups.set(eventKey, {
eventId: eventKey,
tickets: [], tickets: [],
paidCount: 0, paidCount: 0,
pendingCount: 0, pendingCount: 0,
registeredCount: 0 registeredCount: 0
}) })
} }
const group = groups.get(ticket.event)! const group = groups.get(eventKey)!
group.tickets.push(ticket) group.tickets.push(ticket)
if (ticket.paid) { if (ticket.paid) {
group.paidCount++ group.paidCount++
} else { } else {
group.pendingCount++ group.pendingCount++
} }
if (ticket.registered) { if (ticket.registered) {
group.registeredCount++ group.registeredCount++
} }
}) })
// Convert to array and sort by most recent ticket in each group
return Array.from(groups.values()).sort((a, b) => { return Array.from(groups.values()).sort((a, b) => {
const aLatest = Math.max(...a.tickets.map(t => new Date(t.time).getTime())) const aLatest = Math.max(...a.tickets.map(t => new Date(t.time).getTime()))
const bLatest = Math.max(...b.tickets.map(t => new Date(t.time).getTime())) const bLatest = Math.max(...b.tickets.map(t => new Date(t.time).getTime()))
@ -99,7 +102,6 @@ export function useUserTickets() {
}) })
}) })
// Load tickets when authenticated
const loadTickets = async () => { const loadTickets = async () => {
if (isAuthenticated.value && currentUser.value) { if (isAuthenticated.value && currentUser.value) {
await refresh() await refresh()
@ -107,7 +109,6 @@ export function useUserTickets() {
} }
return { return {
// State
tickets: sortedTickets, tickets: sortedTickets,
paidTickets, paidTickets,
pendingTickets, pendingTickets,
@ -116,8 +117,6 @@ export function useUserTickets() {
groupedTickets, groupedTickets,
isLoading, isLoading,
error, error,
// Actions
refresh: loadTickets, refresh: loadTickets,
} }
} }

View file

@ -2,6 +2,7 @@ import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container' import { SERVICE_TOKENS } from '@/core/di-container'
import { ActivitiesNostrService } from './services/ActivitiesNostrService' import { ActivitiesNostrService } from './services/ActivitiesNostrService'
import { TicketApiService, type TicketApiConfig } from './services/TicketApiService' import { TicketApiService, type TicketApiConfig } from './services/TicketApiService'
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
export interface ActivitiesModuleConfig { export interface ActivitiesModuleConfig {
apiConfig: TicketApiConfig apiConfig: TicketApiConfig
@ -70,15 +71,28 @@ export const activitiesModule = createModulePlugin({
}, },
{ {
path: '/my-tickets', path: '/my-tickets',
name: 'my-tickets-v2', name: 'my-tickets',
component: () => import('./views/MyTicketsPage.vue'), component: () => import('./views/MyTicketsPage.vue'),
meta: { meta: {
title: 'My Tickets', title: 'My Tickets',
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: '/events',
name: 'events',
component: () => import('./views/EventsPage.vue'),
meta: {
title: 'Events',
requiresAuth: false,
},
},
], ],
components: {
PurchaseTicketDialog,
},
eventListeners: [ eventListeners: [
{ {
event: 'payment:completed', event: 'payment:completed',
@ -104,6 +118,7 @@ export const activitiesModule = createModulePlugin({
// 2. Register in DI container BEFORE initialization // 2. Register in DI container BEFORE initialization
container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService) container.provide(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE, nostrService)
container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi) container.provide(SERVICE_TOKENS.ACTIVITIES_TICKET_API, ticketApi)
container.provide(SERVICE_TOKENS.TICKET_API, ticketApi)
// 3. Initialize the Nostr service (needs RelayHub dependency) // 3. Initialize the Nostr service (needs RelayHub dependency)
await nostrService.initialize({ await nostrService.initialize({
@ -116,6 +131,7 @@ export const activitiesModule = createModulePlugin({
const { container } = await import('@/core/di-container') const { container } = await import('@/core/di-container')
container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) container.remove(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API) container.remove(SERVICE_TOKENS.ACTIVITIES_TICKET_API)
container.remove(SERVICE_TOKENS.TICKET_API)
}, },
}) })
@ -123,6 +139,6 @@ export default activitiesModule
// Re-export types for external use // Re-export types for external use
export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity' export type { Activity, OrganizerInfo, ActivityTicketInfo } from './types/activity'
export type { ActivityTicket, TicketStatus } from './types/ticket' export type { ActivityTicket, TicketStatus, TicketedEvent, CreateEventRequest } from './types/ticket'
export type { ActivityCategory } from './types/category' export type { ActivityCategory } from './types/category'
export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52' export type { CalendarTimeEvent, CalendarDateEvent, CalendarRSVP } from './types/nip52'

View file

@ -2,6 +2,8 @@ import type {
ActivityTicket, ActivityTicket,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketedEvent,
CreateEventRequest,
} from '../types/ticket' } from '../types/ticket'
export interface TicketApiConfig { export interface TicketApiConfig {
@ -128,6 +130,35 @@ export class TicketApiService {
})) }))
} }
/**
* Create a new ticketed event in LNbits.
*/
async createEvent(eventData: CreateEventRequest, adminKey: string): Promise<TicketedEvent> {
return this.request('/events/api/v1/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify(eventData),
})
}
/**
* Fetch available currencies from LNbits.
*/
async getCurrencies(): Promise<string[]> {
try {
const currencies = await this.request('/api/v1/currencies', { method: 'GET' })
if (Array.isArray(currencies)) {
return ['sats', ...currencies.filter((c: string) => c !== 'sats')]
}
return ['sats']
} catch {
return ['sats']
}
}
/** /**
* Internal fetch helper with standard headers and error handling. * Internal fetch helper with standard headers and error handling.
*/ */

View file

@ -40,3 +40,36 @@ export interface TicketPaymentStatus {
paid: boolean paid: boolean
ticketId?: string ticketId?: string
} }
/**
* LNbits events extension event (database-backed ticketed event).
* Corresponds to the Event model in the events extension.
*/
export interface TicketedEvent {
id: string
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
}
export interface CreateEventRequest {
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
banner?: string | null
}

View file

@ -1,7 +1,5 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useModuleReady } from '@/composables/useModuleReady'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
@ -15,24 +13,12 @@ import CreateEventDialog from '../components/CreateEventDialog.vue'
import { RefreshCw, User, LogIn, Plus } from 'lucide-vue-next' import { RefreshCw, User, LogIn, Plus } from 'lucide-vue-next'
import { formatEventPrice } from '@/lib/utils/formatting' import { formatEventPrice } from '@/lib/utils/formatting'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { EVENTS_API_TOKEN } from '../composables/useEvents' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/event' import type { CreateEventRequest } from '../types/ticket'
// Simple reactive module loading const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
const { isReady: moduleReady, isLoading: moduleLoading, error: moduleError } = useModuleReady('events') const { isAuthenticated, userDisplay } = useAuth()
// Only call services when module is ready - prevents service injection errors
const eventsData = computed(() => moduleReady.value ? useEvents() : null)
const authData = computed(() => moduleReady.value ? useAuth() : null)
// Reactive service data
const upcomingEvents = computed(() => eventsData.value?.upcomingEvents.value ?? [])
const pastEvents = computed(() => eventsData.value?.pastEvents.value ?? [])
const isLoading = computed(() => eventsData.value?.isLoading.value ?? false)
const error = computed(() => eventsData.value?.error.value ?? null)
const refresh = () => eventsData.value?.refresh()
const isAuthenticated = computed(() => authData.value?.isAuthenticated.value ?? false)
const userDisplay = computed(() => authData.value?.userDisplay.value ?? null)
const showPurchaseDialog = ref(false) const showPurchaseDialog = ref(false)
const selectedEvent = ref<{ const selectedEvent = ref<{
id: string id: string
@ -41,18 +27,12 @@ const selectedEvent = ref<{
currency: string currency: string
} | null>(null) } | null>(null)
// Create event dialog state
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
if (!dateStr) return 'Date not available' if (!dateStr) return 'Date not available'
const date = new Date(dateStr) const date = new Date(dateStr)
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) return 'Invalid date'
return 'Invalid date'
}
// Format like "October 5th, 2025" to match the clean UI
return format(date, 'MMMM do, yyyy') return format(date, 'MMMM do, yyyy')
} }
@ -62,45 +42,21 @@ function handlePurchaseClick(event: {
price_per_ticket: number price_per_ticket: number
currency: string currency: string
}) { }) {
if (!isAuthenticated.value) { if (!isAuthenticated.value) return
// Show login prompt or redirect to login
// You could emit an event to show login dialog here
return
}
selectedEvent.value = event selectedEvent.value = event
showPurchaseDialog.value = true showPurchaseDialog.value = true
} }
function handleRetry() {
window.location.reload()
}
// Create event handler
async function handleCreateEvent(eventData: CreateEventRequest) { async function handleCreateEvent(eventData: CreateEventRequest) {
const eventsApi = injectService(EVENTS_API_TOKEN) const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
if (!eventsApi) { const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
throw new Error('Events API not available')
} const adminKey = paymentService?.getPreferredWalletAdminKey?.()
// Get the preferred wallet's admin key using PaymentService
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
if (!paymentService) {
throw new Error('Payment service not available')
}
// Type cast to ensure getPreferredWalletAdminKey method exists
const paymentSvc = paymentService as any
const adminKey = paymentSvc?.getPreferredWalletAdminKey?.()
if (!adminKey) { if (!adminKey) {
throw new Error('No wallet admin key available. Please connect a wallet first.') throw new Error('No wallet admin key available. Please connect a wallet first.')
} }
// Type cast to ensure createEvent method exists await ticketApi.createEvent(eventData, adminKey)
const eventsSvc = eventsApi as any
if (eventsSvc?.createEvent) {
await eventsSvc.createEvent(eventData, adminKey)
}
} }
function handleEventCreated() { function handleEventCreated() {
@ -109,30 +65,7 @@ function handleEventCreated() {
</script> </script>
<template> <template>
<!-- Module Loading State --> <div class="container mx-auto py-8 px-4">
<div v-if="moduleLoading" class="flex flex-col items-center justify-center min-h-screen">
<div class="flex flex-col items-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div class="text-center space-y-2">
<h2 class="text-xl font-semibold">Loading Events...</h2>
<p class="text-sm text-muted-foreground">Loading event management and ticketing system...</p>
</div>
</div>
</div>
<!-- Module Error State -->
<div v-else-if="moduleError" class="flex flex-col items-center justify-center min-h-screen">
<div class="text-center space-y-4">
<h2 class="text-xl font-semibold text-red-600">Failed to load events</h2>
<p class="text-muted-foreground">{{ moduleError }}</p>
<button @click="handleRetry" class="px-4 py-2 bg-primary text-primary-foreground rounded">
Retry
</button>
</div>
</div>
<!-- Events Content - Only render when module is ready -->
<div v-else class="container mx-auto py-8 px-4">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6"> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div class="space-y-1"> <div class="space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">Events</h1> <h1 class="text-2xl sm:text-3xl font-bold text-foreground">Events</h1>
@ -144,20 +77,17 @@ function handleEventCreated() {
</div> </div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground"> <div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<LogIn class="w-4 h-4" /> <LogIn class="w-4 h-4" />
<span class="hidden xs:inline">Please log in to purchase tickets</span> <span>Please log in to purchase tickets</span>
<span class="xs:hidden">Login required</span>
</div> </div>
</div> </div>
<div class="flex gap-2 sm:flex-shrink-0"> <div class="flex gap-2 sm:flex-shrink-0">
<Button v-if="isAuthenticated" variant="default" size="sm" @click="showCreateDialog = true" class="flex-1 sm:flex-none"> <Button v-if="isAuthenticated" variant="default" size="sm" @click="showCreateDialog = true" class="flex-1 sm:flex-none">
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
<span class="ml-2 hidden xs:inline">Create Event</span> <span class="ml-2">Create Event</span>
<span class="ml-2 xs:hidden">Create</span>
</Button> </Button>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading" class="flex-1 sm:flex-none"> <Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading" class="flex-1 sm:flex-none">
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" /> <RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
<span class="ml-2 hidden xs:inline">Refresh</span> <span class="ml-2">Refresh</span>
<span class="ml-2 xs:hidden">Refresh</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -173,7 +103,6 @@ function handleEventCreated() {
</div> </div>
<TabsContent value="upcoming"> <TabsContent value="upcoming">
<!-- {{ upcomingEvents }} -->
<ScrollArea class="h-[600px] w-full pr-4" v-if="upcomingEvents.length"> <ScrollArea class="h-[600px] w-full pr-4" v-if="upcomingEvents.length">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col"> <Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col">
@ -202,9 +131,9 @@ function handleEventCreated() {
</div> </div>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button
class="w-full" class="w-full"
variant="default" variant="default"
:disabled="event.amount_tickets <= event.sold || !isAuthenticated" :disabled="event.amount_tickets <= event.sold || !isAuthenticated"
@click="handlePurchaseClick(event)" @click="handlePurchaseClick(event)"
> >

View file

@ -1,10 +1,316 @@
<script setup lang="ts"> <script setup lang="ts">
// Phase 3 will implement the full tickets view import { onMounted, ref, watch } from 'vue'
import { useUserTickets } from '../composables/useUserTickets'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets,
isLoading,
error,
refresh
} = useUserTickets()
const qrCodes = ref<Record<string, string>>({})
const currentTicketIndex = ref<Record<string, number>>({})
function formatDate(dateStr: string) {
return format(new Date(dateStr), 'PPP')
}
function formatTime(dateStr: string) {
return format(new Date(dateStr), 'HH:mm')
}
function getTicketStatus(ticket: any) {
if (!ticket.paid) return { status: 'pending', label: 'Payment Pending', icon: Clock, color: 'text-yellow-600' }
if (ticket.registered) return { status: 'registered', label: 'Registered', icon: CheckCircle, color: 'text-green-600' }
return { status: 'paid', label: 'Paid', icon: CreditCard, color: 'text-blue-600' }
}
async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
})
qrCodes.value[ticketId] = dataUrl
return dataUrl
} catch (error) {
console.error('Error generating QR code:', error)
return null
}
}
function getCurrentTicketIndex(eventId: string) {
return currentTicketIndex.value[eventId] || 0
}
function setCurrentTicketIndex(eventId: string, index: number) {
currentTicketIndex.value[eventId] = index
}
async function nextTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const nextIndex = (current + 1) % totalTickets
setCurrentTicketIndex(eventId, nextIndex)
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[nextIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
async function prevTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const prevIndex = current === 0 ? totalTickets - 1 : current - 1
setCurrentTicketIndex(eventId, prevIndex)
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[prevIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
function getCurrentTicket(tickets: any[], eventId: string) {
const index = getCurrentTicketIndex(eventId)
return tickets[index] || tickets[0]
}
watch(groupedTickets, async (newGroups) => {
for (const group of newGroups) {
for (const ticket of group.tickets) {
if (!qrCodes.value[ticket.id]) {
await generateQRCode(ticket.id)
}
}
}
}, { immediate: true })
onMounted(async () => {
if (isAuthenticated.value) {
await refresh()
}
})
</script> </script>
<template> <template>
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-foreground">My Tickets</h1> <div class="flex justify-between items-center mb-6">
<p class="text-muted-foreground mt-2">Your purchased tickets will appear here</p> <div class="space-y-1">
<h1 class="text-3xl font-bold text-foreground">My Tickets</h1>
<div v-if="isAuthenticated && userDisplay" class="flex items-center gap-2 text-sm text-muted-foreground">
<User class="w-4 h-4" />
<span>Logged in as {{ userDisplay.name }}</span>
<Badge variant="outline" class="text-xs">{{ userDisplay.shortId }}</Badge>
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="w-4 h-4" />
<span>Please log in to view your tickets</span>
</div>
</div>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
<span v-if="isLoading" class="animate-spin mr-2"></span>
Refresh
</Button>
</div>
<div v-if="!isAuthenticated" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">Please log in to view your tickets</p>
<Button @click="$router.push('/login')">Login</Button>
</div>
<div v-else-if="error" class="mt-4 p-4 bg-destructive/15 text-destructive rounded-lg">
{{ error.message }}
</div>
<div v-else-if="tickets.length === 0 && !isLoading" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
<Button @click="$router.push('/activities')">Browse Activities</Button>
</div>
<div v-else-if="tickets.length > 0">
<Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<!-- All Tickets Tab -->
<TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<Badge variant="outline">
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid · {{ group.pendingCount }} pending · {{ group.registeredCount }} registered
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.length > 0" class="space-y-4">
<div class="flex items-center justify-between">
<Button variant="ghost" size="sm" @click="prevTicket(group.eventId, group.tickets.length)" :disabled="group.tickets.length <= 1">
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.length }}
</span>
<Button variant="ghost" size="sm" @click="nextTicket(group.eventId, group.tickets.length)" :disabled="group.tickets.length <= 1">
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<div v-if="getCurrentTicket(group.tickets, group.eventId)" class="space-y-4">
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets, group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets, group.eventId).id.slice(0, 8) }}
</span>
<Badge :variant="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).status === 'pending' ? 'secondary' : 'default'">
{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets, group.eventId).regTimestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).regTimestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
<TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="paidTickets.length === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">{{ group.paidCount }} paid tickets in this group</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="pendingTickets.length === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">{{ group.pendingCount }} pending tickets in this group</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="registeredTickets.length === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">{{ group.registeredCount }} registered tickets in this group</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div> </div>
</template> </template>

View file

@ -1,99 +0,0 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
import { EventsApiService, type EventsApiConfig } from './services/events-api'
import { useEvents, EVENTS_API_TOKEN } from './composables/useEvents'
export interface EventsModuleConfig {
apiConfig: EventsApiConfig
ticketValidationEndpoint?: string
maxTicketsPerUser?: number
}
/**
* Events Module Plugin
* Provides event management and ticket purchasing functionality
*/
export const eventsModule = createModulePlugin({
name: 'events',
version: '1.0.0',
dependencies: ['base'],
components: {
PurchaseTicketDialog
},
routes: [
{
path: '/events',
name: 'events',
component: () => import('./views/EventsPage.vue'),
meta: {
title: 'Events',
requiresAuth: true
}
},
{
path: '/my-tickets',
name: 'my-tickets',
component: () => import('./views/MyTicketsPage.vue'),
meta: {
title: 'My Tickets',
requiresAuth: true
}
}
],
eventListeners: [
{
event: 'payment:completed',
handler: (event) => {
console.log('Events module: payment completed', event.data)
// Could refresh events or ticket status here
},
description: 'Handle payment completion to refresh ticket status'
},
{
event: 'events:ticket-purchased',
handler: (event) => {
console.log('Ticket purchased:', event.data)
// Other modules can listen to this event
},
description: 'Emit ticket purchase events for other modules'
}
],
onInstall: async (_app, options) => {
const config = options?.config as EventsModuleConfig | undefined
if (!config) {
throw new Error('Events module requires configuration')
}
// Create and register events API service manually since it needs config
const eventsApiService = new EventsApiService(config.apiConfig)
const { container } = await import('@/core/di-container')
container.provide(EVENTS_API_TOKEN, eventsApiService)
},
onUninstall: async () => {
const { container } = await import('@/core/di-container')
container.remove(EVENTS_API_TOKEN)
},
exports: {
composables: {
useEvents
}
}
})
// Override auth logout handler for events-specific cleanup
;(eventsModule as any).handleAuthLogout = () => {
console.log('Events module: user logged out, clearing cache')
// Clear any cached event data if needed
}
export default eventsModule
// Re-export types and composables for external use
export type { Event, Ticket } from './types/event'
export { useEvents } from './composables/useEvents'

View file

@ -1,216 +0,0 @@
// Events API service for the events module
import type { Event, Ticket, CreateEventRequest } from '../types/event'
export interface EventsApiConfig {
baseUrl: string
apiKey: string
}
export class EventsApiService {
constructor(private config: EventsApiConfig) {}
async fetchEvents(): Promise<Event[]> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/events/public`,
{
headers: {
'accept': 'application/json',
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch events'
throw new Error(errorMessage)
}
return await response.json() as Event[]
} catch (error) {
console.error('Error fetching events:', error)
throw error
}
}
async purchaseTicket(eventId: string, userId: string, accessToken: string): Promise<{ payment_hash: string; payment_request: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/${eventId}/user/${userId}`,
{
method: 'GET',
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
'Authorization': `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to purchase ticket'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error purchasing ticket:', error)
throw error
}
}
async checkPaymentStatus(eventId: string, paymentHash: string): Promise<{ paid: boolean; ticket_id?: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/${eventId}/${paymentHash}`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to check payment status'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error checking payment status:', error)
throw error
}
}
async fetchUserTickets(userId: string, accessToken: string): Promise<Ticket[]> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/user/${userId}`,
{
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
'Authorization': `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch user tickets'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error fetching user tickets:', error)
throw error
}
}
async payInvoiceWithWallet(paymentRequest: string, adminKey: string): Promise<{ payment_hash: string; fee_msat: number; preimage: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/api/v1/payments`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify({
out: true,
bolt11: paymentRequest,
}),
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to pay invoice'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error paying invoice:', error)
throw error
}
}
async createEvent(eventData: CreateEventRequest, adminKey: string): Promise<Event> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/events`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify(eventData),
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to create event'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error creating event:', error)
throw error
}
}
async getCurrencies(): Promise<string[]> {
try {
const response = await fetch(
`${this.config.baseUrl}/api/v1/currencies`,
{
headers: {
'accept': 'application/json',
},
}
)
if (!response.ok) {
// If API call fails, return default currencies
console.warn('Failed to fetch currencies from API, using defaults')
return ['sats']
}
const apiCurrencies = await response.json()
// Combine 'sats' with API currencies, following the pattern from market API
if (Array.isArray(apiCurrencies)) {
return ['sats', ...apiCurrencies.filter((currency: string) => currency !== 'sats')]
}
return ['sats']
} catch (error) {
console.error('Error fetching currencies:', error)
return ['sats']
}
}
}

View file

@ -1,49 +0,0 @@
export interface Event {
id: string
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
}
export interface Ticket {
id: string
wallet: string
event: string
name: string | null
email: string | null
user_id: string | null
registered: boolean
paid: boolean
time: string
reg_timestamp: string
}
export interface CreateEventRequest {
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
banner?: string | null
}
export interface EventsApiError {
detail: Array<{
loc: [string, number]
msg: string
type: string
}>
}

View file

@ -1,599 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useUserTickets } from '../composables/useUserTickets'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets,
isLoading,
error,
refresh
} = useUserTickets()
// QR code state - now always generate QR codes for all tickets
const qrCodes = ref<Record<string, string>>({})
// Ticket cycling state
const currentTicketIndex = ref<Record<string, number>>({})
function formatDate(dateStr: string) {
return format(new Date(dateStr), 'PPP')
}
function formatTime(dateStr: string) {
return format(new Date(dateStr), 'HH:mm')
}
function getTicketStatus(ticket: any) {
if (!ticket.paid) return { status: 'pending', label: 'Payment Pending', icon: Clock, color: 'text-yellow-600' }
if (ticket.registered) return { status: 'registered', label: 'Registered', icon: CheckCircle, color: 'text-green-600' }
return { status: 'paid', label: 'Paid', icon: CreditCard, color: 'text-blue-600' }
}
async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200, // Larger QR code for easier scanning
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCodes.value[ticketId] = dataUrl
return dataUrl
} catch (error) {
console.error('Error generating QR code:', error)
return null
}
}
// Ticket cycling functions
function getCurrentTicketIndex(eventId: string) {
return currentTicketIndex.value[eventId] || 0
}
function setCurrentTicketIndex(eventId: string, index: number) {
currentTicketIndex.value[eventId] = index
}
async function nextTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const nextIndex = (current + 1) % totalTickets
setCurrentTicketIndex(eventId, nextIndex)
// Generate QR code for the new ticket if needed
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[nextIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
async function prevTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const prevIndex = current === 0 ? totalTickets - 1 : current - 1
setCurrentTicketIndex(eventId, prevIndex)
// Generate QR code for the new ticket if needed
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[prevIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
function getCurrentTicket(tickets: any[], eventId: string) {
const index = getCurrentTicketIndex(eventId)
return tickets[index] || tickets[0]
}
// Watch for changes in grouped tickets and generate QR codes
watch(groupedTickets, async (newGroups) => {
for (const group of newGroups) {
for (const ticket of group.tickets) {
if (!qrCodes.value[ticket.id]) {
await generateQRCode(ticket.id)
}
}
}
}, { immediate: true })
onMounted(async () => {
if (isAuthenticated.value) {
await refresh()
}
})
</script>
<template>
<div class="container mx-auto py-8 px-4">
<div class="flex justify-between items-center mb-6">
<div class="space-y-1">
<h1 class="text-3xl font-bold text-foreground">My Tickets</h1>
<div v-if="isAuthenticated && userDisplay" class="flex items-center gap-2 text-sm text-muted-foreground">
<User class="w-4 h-4" />
<span>Logged in as {{ userDisplay.name }}</span>
<Badge variant="outline" class="text-xs">{{ userDisplay.shortId }}</Badge>
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="w-4 h-4" />
<span>Please log in to view your tickets</span>
</div>
</div>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
<span v-if="isLoading" class="animate-spin mr-2"></span>
Refresh
</Button>
</div>
<div v-if="!isAuthenticated" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">Please log in to view your tickets</p>
<Button @click="$router.push('/login')">Login</Button>
</div>
<div v-else-if="error" class="mt-4 p-4 bg-destructive/15 text-destructive rounded-lg">
{{ error.message }}
</div>
<div v-else-if="tickets.length === 0 && !isLoading" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
<Button @click="$router.push('/events')">Browse Events</Button>
</div>
<div v-else-if="tickets.length > 0">
<Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="outline">
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid {{ group.pendingCount }} pending {{ group.registeredCount }} registered
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.length)"
:disabled="group.tickets.length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.length)"
:disabled="group.tickets.length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets, group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets, group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets, group.eventId).id.slice(0, 8) }}
</span>
<Badge :variant="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).status === 'pending' ? 'secondary' : 'default'">
{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets, group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="default">
{{ group.paidCount }} paid
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => t.paid).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => t.paid).length)"
:disabled="group.tickets.filter(t => t.paid).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => t.paid).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => t.paid).length)"
:disabled="group.tickets.filter(t => t.paid).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="default">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="secondary">
{{ group.pendingCount }} pending
</Badge>
</div>
<CardDescription>
{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => !t.paid).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => !t.paid).length)"
:disabled="group.tickets.filter(t => !t.paid).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => !t.paid).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => !t.paid).length)"
:disabled="group.tickets.filter(t => !t.paid).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="secondary">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Created:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).time) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="default">
{{ group.registeredCount }} registered
</Badge>
</div>
<CardDescription>
{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => t.registered).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => t.registered).length)"
:disabled="group.tickets.filter(t => t.registered).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => t.registered).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => t.registered).length)"
:disabled="group.tickets.filter(t => t.registered).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="default">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</template>