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: {
name: 'activities',
enabled: true,

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { payInvoiceWithWallet } from '@/lib/api/events'
import { config } from '@/lib/config'
import { toast } from 'vue-sonner'
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
*/
@ -224,9 +259,8 @@ export class PaymentService extends BaseService {
this.debug(`Paying invoice with wallet: ${wallet.id.slice(0, 8)}`)
// Make payment
const paymentResult = await payInvoiceWithWallet(
const paymentResult = await this.payInvoice(
paymentRequest,
wallet.id,
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 { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { EVENTS_API_TOKEN } from '../composables/useEvents'
import type { CreateEventRequest } from '../types/event'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
// Props
interface Props {
open: boolean
onCreateEvent: (eventData: CreateEventRequest) => Promise<void>
@ -47,8 +46,6 @@ const emit = defineEmits<{
'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({
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"),
@ -67,7 +64,6 @@ const formSchema = toTypedSchema(z.object({
path: ["event_end_date"]
}))
// Form setup
const form = useForm({
validationSchema: formSchema,
initialValues: {
@ -82,29 +78,19 @@ const form = useForm({
}
})
// Get PaymentService for wallet selection
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 loadingCurrencies = ref(false)
// Load currencies when dialog opens
watch(() => props.open, async (isOpen) => {
if (isOpen && eventsApi && !loadingCurrencies.value) {
if (isOpen && ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true
try {
// Type cast to ensure getCurrencies method exists
const apiService = eventsApi as any
if (apiService.getCurrencies) {
availableCurrencies.value = await apiService.getCurrencies()
}
availableCurrencies.value = await ticketApi.getCurrencies()
} catch (error) {
console.warn('Failed to load currencies:', error)
// Keep default currencies
} finally {
loadingCurrencies.value = false
}
@ -115,10 +101,8 @@ const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
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'))
// Form submission
const onSubmit = form.handleSubmit(async (formValues) => {
if (!isFormValid.value) return
@ -127,7 +111,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
return
}
// Type cast to ensure getPreferredWallet method exists
const paymentSvc = paymentService as any
const preferredWallet = paymentSvc?.getPreferredWallet?.()
if (!preferredWallet) {
@ -137,11 +120,10 @@ const onSubmit = form.handleSubmit(async (formValues) => {
isLoading.value = true
try {
// Add the selected wallet ID and set closing_date to event_end_date
const eventData: CreateEventRequest = {
...formValues,
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)
@ -157,7 +139,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
}
})
// Handle dialog close
const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) {
resetForm()
@ -180,47 +161,33 @@ const handleOpenChange = (open: boolean) => {
</DialogHeader>
<form @submit="onSubmit" class="space-y-6 mt-4">
<!-- Event Name -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Event Name *</FormLabel>
<FormControl>
<Input
placeholder="Enter event name"
v-bind="componentField"
/>
<Input placeholder="Enter event name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Event Description -->
<FormField v-slot="{ componentField }" name="info">
<FormItem>
<FormLabel>Event Description *</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your event..."
rows="3"
v-bind="componentField"
/>
<Textarea placeholder="Describe your event..." rows="3" v-bind="componentField" />
</FormControl>
<FormDescription>Provide details about your event</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Date Fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="event_start_date">
<FormItem>
<FormLabel>Event Starts *</FormLabel>
<FormControl>
<Input
type="date"
:min="today"
v-bind="componentField"
/>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@ -230,30 +197,19 @@ const handleOpenChange = (open: boolean) => {
<FormItem>
<FormLabel>Event Ends *</FormLabel>
<FormControl>
<Input
type="date"
:min="today"
v-bind="componentField"
/>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Ticket Details -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Total Tickets *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="100000"
placeholder="100"
v-bind="componentField"
/>
<Input type="number" min="1" max="100000" placeholder="100" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@ -263,13 +219,7 @@ const handleOpenChange = (open: boolean) => {
<FormItem>
<FormLabel>Price per Ticket *</FormLabel>
<FormControl>
<Input
type="number"
min="0"
step="0.01"
placeholder="1000"
v-bind="componentField"
/>
<Input type="number" min="0" step="0.01" placeholder="1000" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@ -284,11 +234,7 @@ const handleOpenChange = (open: boolean) => {
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
@ -303,36 +249,22 @@ const handleOpenChange = (open: boolean) => {
</FormField>
</div>
<!-- Banner URL (Optional) -->
<FormField v-slot="{ componentField }" name="banner">
<FormItem>
<FormLabel>Banner Image URL (Optional)</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com/banner.jpg"
v-bind="componentField"
/>
<Input type="url" placeholder="https://example.com/banner.jpg" v-bind="componentField" />
</FormControl>
<FormDescription>URL to an image for your event banner</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
Cancel
</Button>
<Button
type="submit"
:disabled="isLoading || !isFormValid"
>
<Button type="submit" :disabled="isLoading || !isFormValid">
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ isLoading ? 'Creating...' : 'Create Event' }}
</Button>

View file

@ -1,4 +1,3 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onUnmounted } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
@ -31,7 +30,6 @@ const {
isLoading,
error,
paymentHash,
qrCode,
isPaymentPending,
isPayingWithWallet,
@ -62,7 +60,6 @@ function handleClose() {
resetPaymentState()
}
// Cleanup on unmount
onUnmounted(() => {
cleanup()
})

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,8 @@ import type {
ActivityTicket,
TicketPurchaseInvoice,
TicketPaymentStatus,
TicketedEvent,
CreateEventRequest,
} from '../types/ticket'
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.
*/

View file

@ -40,3 +40,36 @@ export interface TicketPaymentStatus {
paid: boolean
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">
import { ref, computed } from 'vue'
import { useModuleReady } from '@/composables/useModuleReady'
import { ref } from 'vue'
import { useEvents } from '../composables/useEvents'
import { useAuth } from '@/composables/useAuthService'
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 { formatEventPrice } from '@/lib/utils/formatting'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { EVENTS_API_TOKEN } from '../composables/useEvents'
import type { CreateEventRequest } from '../types/event'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
// Simple reactive module loading
const { isReady: moduleReady, isLoading: moduleLoading, error: moduleError } = useModuleReady('events')
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
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 selectedEvent = ref<{
id: string
@ -41,18 +27,12 @@ const selectedEvent = ref<{
currency: string
} | null>(null)
// Create event dialog state
const showCreateDialog = ref(false)
function formatDate(dateStr: string) {
if (!dateStr) return 'Date not available'
const date = new Date(dateStr)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
// Format like "October 5th, 2025" to match the clean UI
if (isNaN(date.getTime())) return 'Invalid date'
return format(date, 'MMMM do, yyyy')
}
@ -62,45 +42,21 @@ function handlePurchaseClick(event: {
price_per_ticket: number
currency: string
}) {
if (!isAuthenticated.value) {
// Show login prompt or redirect to login
// You could emit an event to show login dialog here
return
}
if (!isAuthenticated.value) return
selectedEvent.value = event
showPurchaseDialog.value = true
}
function handleRetry() {
window.location.reload()
}
// Create event handler
async function handleCreateEvent(eventData: CreateEventRequest) {
const eventsApi = injectService(EVENTS_API_TOKEN)
if (!eventsApi) {
throw new Error('Events API not available')
}
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
// 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?.()
const adminKey = paymentService?.getPreferredWalletAdminKey?.()
if (!adminKey) {
throw new Error('No wallet admin key available. Please connect a wallet first.')
}
// Type cast to ensure createEvent method exists
const eventsSvc = eventsApi as any
if (eventsSvc?.createEvent) {
await eventsSvc.createEvent(eventData, adminKey)
}
await ticketApi.createEvent(eventData, adminKey)
}
function handleEventCreated() {
@ -109,30 +65,7 @@ function handleEventCreated() {
</script>
<template>
<!-- Module Loading State -->
<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="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="space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">Events</h1>
@ -144,20 +77,17 @@ function handleEventCreated() {
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<LogIn class="w-4 h-4" />
<span class="hidden xs:inline">Please log in to purchase tickets</span>
<span class="xs:hidden">Login required</span>
<span>Please log in to purchase tickets</span>
</div>
</div>
<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">
<Plus class="w-4 h-4" />
<span class="ml-2 hidden xs:inline">Create Event</span>
<span class="ml-2 xs:hidden">Create</span>
<span class="ml-2">Create Event</span>
</Button>
<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 }" />
<span class="ml-2 hidden xs:inline">Refresh</span>
<span class="ml-2 xs:hidden">Refresh</span>
<span class="ml-2">Refresh</span>
</Button>
</div>
</div>
@ -173,7 +103,6 @@ function handleEventCreated() {
</div>
<TabsContent value="upcoming">
<!-- {{ upcomingEvents }} -->
<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">
<Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col">

View file

@ -1,10 +1,316 @@
<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>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground">My Tickets</h1>
<p class="text-muted-foreground mt-2">Your purchased tickets will appear here</p>
<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('/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>
</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>