Enhance market module with new chat and events features
- Introduce chat module with components, services, and composables for real-time messaging. - Implement events module with API service, components, and ticket purchasing functionality. - Update app configuration to include new modules and their respective settings. - Refactor existing components to integrate with the new chat and events features. - Enhance market store and services to support new functionalities and improve order management. - Update routing to accommodate new views for chat and events, ensuring seamless navigation.
This commit is contained in:
parent
519a9003d4
commit
e40ac91417
46 changed files with 6305 additions and 3264 deletions
255
src/modules/events/components/PurchaseTicketDialog.vue
Normal file
255
src/modules/events/components/PurchaseTicketDialog.vue
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<!-- 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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useTicketPurchase } from '@/composables/useTicketPurchase'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
|
||||
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||
|
||||
interface Props {
|
||||
event: {
|
||||
id: string
|
||||
name: string
|
||||
price_per_ticket: number
|
||||
currency: string
|
||||
}
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:isOpen', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { isAuthenticated, userDisplay } = useAuth()
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
paymentHash,
|
||||
|
||||
qrCode,
|
||||
isPaymentPending,
|
||||
isPayingWithWallet,
|
||||
canPurchase,
|
||||
userWallets,
|
||||
hasWalletWithBalance,
|
||||
purchaseTicketForEvent,
|
||||
handleOpenLightningWallet,
|
||||
resetPaymentState,
|
||||
cleanup,
|
||||
ticketQRCode,
|
||||
purchasedTicketId,
|
||||
showTicketQR
|
||||
} = useTicketPurchase()
|
||||
|
||||
async function handlePurchase() {
|
||||
if (!canPurchase.value) return
|
||||
|
||||
try {
|
||||
await purchaseTicketForEvent(props.event.id)
|
||||
} catch (err) {
|
||||
console.error('Error purchasing ticket:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:isOpen', false)
|
||||
resetPaymentState()
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="handleClose">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<CreditCard class="w-5 h-5" />
|
||||
Purchase Ticket
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Authentication Check -->
|
||||
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<User class="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold">Login Required</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Please log in to your account to purchase tickets using your wallet.
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="handleClose" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- User Info and Purchase -->
|
||||
<div v-else-if="!paymentHash" class="py-4 space-y-4">
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Purchasing as:</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Name:</span>
|
||||
<span class="text-sm font-medium">{{ userDisplay?.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">User ID:</span>
|
||||
<Badge variant="secondary" class="text-xs font-mono">{{ userDisplay?.shortId }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Information -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Wallet Status:</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-if="userWallets.length === 0" class="text-sm text-muted-foreground">
|
||||
No wallets found
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="wallet in userWallets" :key="wallet.id" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{{ wallet.name }}</span>
|
||||
<Badge v-if="wallet.balance_msat > 0" variant="default" class="text-xs">
|
||||
{{ formatWalletBalance(wallet.balance_msat) }}
|
||||
</Badge>
|
||||
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Auto-payment available</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>No funds available, fill your wallet or pay with an external one</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Payment Details:</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Event:</span>
|
||||
<span class="text-sm font-medium">{{ event.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Price:</span>
|
||||
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="handlePurchase"
|
||||
:disabled="isLoading || !canPurchase"
|
||||
class="w-full"
|
||||
>
|
||||
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
||||
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
||||
<Zap class="w-4 h-4" />
|
||||
Pay with Wallet
|
||||
</span>
|
||||
<span v-else>Generate Payment Request</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment QR Code and Status -->
|
||||
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-lg font-semibold">Payment Required</h3>
|
||||
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
|
||||
Processing payment with your wallet...
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
Scan the QR code with your Lightning wallet to complete the payment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
|
||||
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 w-full">
|
||||
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
|
||||
<div v-if="isPaymentPending" class="text-center space-y-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Payment will be confirmed automatically once received
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket QR Code (After Successful Purchase) -->
|
||||
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Your ticket has been purchased and is now available in your tickets area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/50 rounded-lg p-4 w-full">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="flex justify-center">
|
||||
<Ticket class="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">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">{{ purchasedTicketId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 w-full">
|
||||
<Button @click="() => $router.push('/my-tickets')" class="w-full">
|
||||
View My Tickets
|
||||
</Button>
|
||||
<Button variant="outline" @click="handleClose" class="w-full">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
65
src/modules/events/composables/useEvents.ts
Normal file
65
src/modules/events/composables/useEvents.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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')
|
||||
|
||||
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 { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
|
||||
() => eventsApi.fetchEvents(),
|
||||
[] as Event[],
|
||||
{
|
||||
immediate: true,
|
||||
resetOnExecute: false,
|
||||
}
|
||||
)
|
||||
|
||||
const error = computed(() => {
|
||||
if (asyncError.value) {
|
||||
return {
|
||||
message: asyncError.value instanceof Error
|
||||
? asyncError.value.message
|
||||
: 'An error occurred while fetching events'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
return [...events.value].sort((a, b) =>
|
||||
new Date(b.time).getTime() - new Date(a.time).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date()
|
||||
return sortedEvents.value.filter(event =>
|
||||
new Date(event.event_start_date) > now
|
||||
)
|
||||
})
|
||||
|
||||
const pastEvents = computed(() => {
|
||||
const now = new Date()
|
||||
return sortedEvents.value.filter(event =>
|
||||
new Date(event.event_end_date) < now
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
events: sortedEvents,
|
||||
upcomingEvents,
|
||||
pastEvents,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
242
src/modules/events/composables/useTicketPurchase.ts
Normal file
242
src/modules/events/composables/useTicketPurchase.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
export function useTicketPurchase() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const paymentHash = ref<string | null>(null)
|
||||
const paymentRequest = ref<string | null>(null)
|
||||
const qrCode = ref<string | null>(null)
|
||||
const isPaymentPending = ref(false)
|
||||
const isPayingWithWallet = ref(false)
|
||||
|
||||
// Ticket QR code state
|
||||
const ticketQRCode = ref<string | null>(null)
|
||||
const purchasedTicketId = ref<string | null>(null)
|
||||
const showTicketQR = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
|
||||
const userDisplay = computed(() => {
|
||||
if (!currentUser.value) return null
|
||||
return {
|
||||
name: currentUser.value.username || currentUser.value.id,
|
||||
shortId: currentUser.value.id.slice(0, 8)
|
||||
}
|
||||
})
|
||||
|
||||
const userWallets = computed(() => currentUser.value?.wallets || [])
|
||||
const hasWalletWithBalance = computed(() =>
|
||||
userWallets.value.some((wallet: any) => wallet.balance_msat > 0)
|
||||
)
|
||||
|
||||
// Generate QR code for Lightning payment
|
||||
async function generateQRCode(bolt11: string) {
|
||||
try {
|
||||
const qrcode = await import('qrcode')
|
||||
const dataUrl = await qrcode.toDataURL(bolt11, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCode.value = dataUrl
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err)
|
||||
error.value = 'Failed to generate QR code'
|
||||
}
|
||||
}
|
||||
|
||||
// Generate QR code for ticket
|
||||
async function generateTicketQRCode(ticketId: string) {
|
||||
try {
|
||||
const qrcode = await import('qrcode')
|
||||
const ticketUrl = `ticket://${ticketId}`
|
||||
const dataUrl = await qrcode.toDataURL(ticketUrl, {
|
||||
width: 128,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
ticketQRCode.value = dataUrl
|
||||
return dataUrl
|
||||
} catch (error) {
|
||||
console.error('Error generating ticket QR code:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pay with wallet
|
||||
async function payWithWallet(paymentRequest: string) {
|
||||
const walletWithBalance = userWallets.value.find((wallet: any) => wallet.balance_msat > 0)
|
||||
|
||||
if (!walletWithBalance) {
|
||||
throw new Error('No wallet with sufficient balance found')
|
||||
}
|
||||
|
||||
try {
|
||||
await payInvoiceWithWallet(paymentRequest, walletWithBalance.id, walletWithBalance.adminkey)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Wallet payment failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Purchase ticket for event
|
||||
async function purchaseTicketForEvent(eventId: string) {
|
||||
if (!canPurchase.value) {
|
||||
throw new Error('User must be authenticated to purchase tickets')
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
paymentHash.value = null
|
||||
paymentRequest.value = null
|
||||
qrCode.value = null
|
||||
ticketQRCode.value = null
|
||||
purchasedTicketId.value = null
|
||||
showTicketQR.value = false
|
||||
|
||||
try {
|
||||
// Get the invoice
|
||||
const invoice = await purchaseTicket(eventId)
|
||||
paymentHash.value = invoice.payment_hash
|
||||
paymentRequest.value = invoice.payment_request
|
||||
|
||||
// Generate QR code for payment
|
||||
await generateQRCode(invoice.payment_request)
|
||||
|
||||
// Try to pay with wallet if available
|
||||
if (hasWalletWithBalance.value) {
|
||||
isPayingWithWallet.value = true
|
||||
try {
|
||||
await payWithWallet(invoice.payment_request)
|
||||
// If wallet payment succeeds, proceed to check payment status
|
||||
await startPaymentStatusCheck(eventId, invoice.payment_hash)
|
||||
} catch (walletError) {
|
||||
// If wallet payment fails, fall back to manual payment
|
||||
console.log('Wallet payment failed, falling back to manual payment:', walletError)
|
||||
isPayingWithWallet.value = false
|
||||
await startPaymentStatusCheck(eventId, invoice.payment_hash)
|
||||
}
|
||||
} else {
|
||||
// No wallet balance, proceed with manual payment
|
||||
await startPaymentStatusCheck(eventId, invoice.payment_hash)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to purchase ticket'
|
||||
console.error('Error purchasing ticket:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if (result.paid) {
|
||||
isPaymentPending.value = false
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
|
||||
// Generate ticket QR code
|
||||
if (result.ticket_id) {
|
||||
purchasedTicketId.value = result.ticket_id
|
||||
await generateTicketQRCode(result.ticket_id)
|
||||
showTicketQR.value = true
|
||||
}
|
||||
|
||||
toast.success('Ticket purchased successfully!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking payment status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
paymentHash.value = null
|
||||
paymentRequest.value = null
|
||||
qrCode.value = null
|
||||
isPaymentPending.value = false
|
||||
isPayingWithWallet.value = false
|
||||
ticketQRCode.value = null
|
||||
purchasedTicketId.value = null
|
||||
showTicketQR.value = false
|
||||
}
|
||||
|
||||
// Open Lightning wallet
|
||||
function handleOpenLightningWallet() {
|
||||
if (paymentRequest.value) {
|
||||
window.open(`lightning:${paymentRequest.value}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
function cleanup() {
|
||||
stopPaymentStatusCheck()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
paymentHash,
|
||||
paymentRequest,
|
||||
qrCode,
|
||||
isPaymentPending,
|
||||
isPayingWithWallet,
|
||||
ticketQRCode,
|
||||
purchasedTicketId,
|
||||
showTicketQR,
|
||||
|
||||
// Computed
|
||||
canPurchase,
|
||||
userDisplay,
|
||||
userWallets,
|
||||
hasWalletWithBalance,
|
||||
|
||||
// Actions
|
||||
purchaseTicketForEvent,
|
||||
handleOpenLightningWallet,
|
||||
resetPaymentState,
|
||||
cleanup,
|
||||
generateTicketQRCode
|
||||
}
|
||||
}
|
||||
123
src/modules/events/composables/useUserTickets.ts
Normal file
123
src/modules/events/composables/useUserTickets.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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/useAuth'
|
||||
|
||||
interface GroupedTickets {
|
||||
eventId: string
|
||||
tickets: Ticket[]
|
||||
paidCount: number
|
||||
pendingCount: number
|
||||
registeredCount: number
|
||||
}
|
||||
|
||||
export function useUserTickets() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
||||
const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState(
|
||||
async () => {
|
||||
if (!isAuthenticated.value || !currentUser.value) {
|
||||
return []
|
||||
}
|
||||
return await fetchUserTickets(currentUser.value.id)
|
||||
},
|
||||
[] as Ticket[],
|
||||
{
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
}
|
||||
)
|
||||
|
||||
const error = computed(() => {
|
||||
if (asyncError.value) {
|
||||
return {
|
||||
message: asyncError.value instanceof Error
|
||||
? asyncError.value.message
|
||||
: 'An error occurred while fetching tickets'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const sortedTickets = computed(() => {
|
||||
return [...tickets.value].sort((a, b) =>
|
||||
new Date(b.time).getTime() - new Date(a.time).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
const paidTickets = computed(() => {
|
||||
return sortedTickets.value.filter(ticket => ticket.paid)
|
||||
})
|
||||
|
||||
const pendingTickets = computed(() => {
|
||||
return sortedTickets.value.filter(ticket => !ticket.paid)
|
||||
})
|
||||
|
||||
const registeredTickets = computed(() => {
|
||||
return sortedTickets.value.filter(ticket => ticket.registered)
|
||||
})
|
||||
|
||||
const unregisteredTickets = computed(() => {
|
||||
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,
|
||||
tickets: [],
|
||||
paidCount: 0,
|
||||
pendingCount: 0,
|
||||
registeredCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
const group = groups.get(ticket.event)!
|
||||
group.tickets.push(ticket)
|
||||
|
||||
if (ticket.paid) {
|
||||
group.paidCount++
|
||||
} else {
|
||||
group.pendingCount++
|
||||
}
|
||||
|
||||
if (ticket.registered) {
|
||||
group.registeredCount++
|
||||
}
|
||||
})
|
||||
|
||||
// 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()))
|
||||
return bLatest - aLatest
|
||||
})
|
||||
})
|
||||
|
||||
// Load tickets when authenticated
|
||||
const loadTickets = async () => {
|
||||
if (isAuthenticated.value && currentUser.value) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tickets: sortedTickets,
|
||||
paidTickets,
|
||||
pendingTickets,
|
||||
registeredTickets,
|
||||
unregisteredTickets,
|
||||
groupedTickets,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
refresh: loadTickets,
|
||||
}
|
||||
}
|
||||
116
src/modules/events/index.ts
Normal file
116
src/modules/events/index.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { App } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { container } from '@/core/di-container'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
|
||||
// Import components and services
|
||||
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: ModulePlugin = {
|
||||
name: 'events',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
async install(app: App, options?: { config?: EventsModuleConfig }) {
|
||||
console.log('🎫 Installing events module...')
|
||||
|
||||
const config = options?.config
|
||||
if (!config) {
|
||||
throw new Error('Events module requires configuration')
|
||||
}
|
||||
|
||||
// Create and register events API service
|
||||
const eventsApiService = new EventsApiService(config.apiConfig)
|
||||
container.provide(EVENTS_API_TOKEN, eventsApiService)
|
||||
|
||||
// Register global components
|
||||
app.component('PurchaseTicketDialog', PurchaseTicketDialog)
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
setupEventListeners()
|
||||
|
||||
console.log('✅ Events module installed successfully')
|
||||
},
|
||||
|
||||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling events module...')
|
||||
|
||||
// Clean up services
|
||||
container.remove(EVENTS_API_TOKEN)
|
||||
|
||||
console.log('✅ Events module uninstalled')
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
components: {
|
||||
PurchaseTicketDialog
|
||||
},
|
||||
|
||||
composables: {
|
||||
useEvents
|
||||
},
|
||||
|
||||
services: {
|
||||
eventsApi: EVENTS_API_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
function setupEventListeners() {
|
||||
// Listen for auth events
|
||||
eventBus.on('auth:logout', () => {
|
||||
// Clear any cached event data if needed
|
||||
console.log('Events module: user logged out, clearing cache')
|
||||
})
|
||||
|
||||
// Listen for payment events from other modules
|
||||
eventBus.on('payment:completed', (event) => {
|
||||
console.log('Events module: payment completed', event.data)
|
||||
// Could refresh events or ticket status here
|
||||
})
|
||||
|
||||
// Emit events for other modules
|
||||
eventBus.on('events:ticket-purchased', (event) => {
|
||||
console.log('Ticket purchased:', event.data)
|
||||
// Other modules can listen to this event
|
||||
})
|
||||
}
|
||||
|
||||
export default eventsModule
|
||||
|
||||
// Re-export types and composables for external use
|
||||
export type { Event, Ticket } from './types/event'
|
||||
export { useEvents } from './composables/useEvents'
|
||||
155
src/modules/events/services/events-api.ts
Normal file
155
src/modules/events/services/events-api.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Events API service for the events module
|
||||
import type { Event, Ticket } 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/modules/events/types/event.ts
Normal file
36
src/modules/events/types/event.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
}>
|
||||
}
|
||||
168
src/modules/events/views/EventsPage.vue
Normal file
168
src/modules/events/views/EventsPage.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { Card, CardContent, CardDescription, CardFooter, 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 PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import { RefreshCw, User, LogIn } from 'lucide-vue-next'
|
||||
import { formatEventPrice } from '@/lib/utils/formatting'
|
||||
|
||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||
const { isAuthenticated, userDisplay } = useAuth()
|
||||
const showPurchaseDialog = ref(false)
|
||||
const selectedEvent = ref<{
|
||||
id: string
|
||||
name: string
|
||||
price_per_ticket: number
|
||||
currency: string
|
||||
} | null>(null)
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return format(new Date(dateStr), 'PPP')
|
||||
}
|
||||
|
||||
function handlePurchaseClick(event: {
|
||||
id: string
|
||||
name: string
|
||||
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
|
||||
}
|
||||
|
||||
selectedEvent.value = event
|
||||
showPurchaseDialog.value = true
|
||||
}
|
||||
</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">Events</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">
|
||||
<LogIn class="w-4 h-4" />
|
||||
<span>Please log in to purchase tickets</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
|
||||
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': isLoading }" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs default-value="upcoming" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upcoming">Upcoming Events</TabsTrigger>
|
||||
<TabsTrigger value="past">Past Events</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div v-if="error" class="mt-4 p-4 bg-destructive/15 text-destructive rounded-lg">
|
||||
{{ error.message }}
|
||||
</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">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
|
||||
<CardDescription>{{ event.info }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex-grow">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Start Date:</span>
|
||||
<span class="text-foreground">{{ formatDate(event.event_start_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">End Date:</span>
|
||||
<span class="text-foreground">{{ formatDate(event.event_end_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tickets Available:</span>
|
||||
<span class="text-foreground">{{ event.amount_tickets - event.sold }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Price:</span>
|
||||
<span class="text-foreground">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="default"
|
||||
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
|
||||
@click="handlePurchaseClick(event)"
|
||||
>
|
||||
<span v-if="!isAuthenticated" class="flex items-center gap-2">
|
||||
<LogIn class="w-4 h-4" />
|
||||
Login to Purchase
|
||||
</span>
|
||||
<span v-else>Buy Ticket</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-else-if="!isLoading" class="text-center py-8 text-muted-foreground">
|
||||
No upcoming events found
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past">
|
||||
<ScrollArea class="h-[600px] w-full pr-4" v-if="pastEvents.length">
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="event in pastEvents" :key="event.id" class="flex flex-col opacity-75">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
|
||||
<CardDescription>{{ event.info }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex-grow">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Start Date:</span>
|
||||
<span class="text-foreground">{{ formatDate(event.event_start_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">End Date:</span>
|
||||
<span class="text-foreground">{{ formatDate(event.event_end_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Total Tickets:</span>
|
||||
<span class="text-foreground">{{ event.amount_tickets }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tickets Sold:</span>
|
||||
<span class="text-foreground">{{ event.sold }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-else-if="!isLoading" class="text-center py-8 text-muted-foreground">
|
||||
No past events found
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
|
||||
</div>
|
||||
</template>
|
||||
599
src/modules/events/views/MyTicketsPage.vue
Normal file
599
src/modules/events/views/MyTicketsPage.vue
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<!-- 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/useAuth'
|
||||
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