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:
parent
bac9fd091e
commit
e61d3c4d46
21 changed files with 547 additions and 1480 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}>
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue