feat: Add My Tickets feature with ticket management

- Introduce MyTickets.vue page to display user tickets with filtering options for paid, pending, and registered tickets.
- Implement useUserTickets composable for fetching and managing user ticket data.
- Update Navbar.vue to include a link to the My Tickets page.
- Enhance events API to support fetching user tickets.
- Define Ticket type in event.ts for better type safety.
This commit is contained in:
padreug 2025-08-01 21:54:05 +02:00
parent f7450627bc
commit 63d636a8a0
6 changed files with 403 additions and 2 deletions

View file

@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Sun, Moon, Menu, X, User, LogOut } from 'lucide-vue-next' import { Sun, Moon, Menu, X, User, LogOut, Ticket } from 'lucide-vue-next'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue' import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import LoginDialog from '@/components/auth/LoginDialog.vue' import LoginDialog from '@/components/auth/LoginDialog.vue'
import ProfileDialog from '@/components/auth/ProfileDialog.vue' import ProfileDialog from '@/components/auth/ProfileDialog.vue'
@ -27,6 +27,7 @@ const showProfileDialog = ref(false)
const navigation = computed<NavigationItem[]>(() => [ const navigation = computed<NavigationItem[]>(() => [
{ name: t('nav.home'), href: '/' }, { name: t('nav.home'), href: '/' },
{ name: t('nav.events'), href: '/events' }, { name: t('nav.events'), href: '/events' },
{ name: 'My Tickets', href: '/my-tickets' },
{ name: t('nav.support'), href: '/support' }, { name: t('nav.support'), href: '/support' },
]) ])
@ -98,6 +99,10 @@ const handleLogout = async () => {
<User class="h-4 w-4" /> <User class="h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="() => router.push('/my-tickets')" class="gap-2">
<Ticket class="h-4 w-4" />
My Tickets
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogout" class="gap-2 text-destructive"> <DropdownMenuItem @click="handleLogout" class="gap-2 text-destructive">
<LogOut class="h-4 w-4" /> <LogOut class="h-4 w-4" />

View file

@ -0,0 +1,77 @@
import { ref, computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import type { Ticket } from '@/lib/types/event'
import { fetchUserTickets } from '@/lib/api/events'
import { useAuth } from './useAuth'
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)
})
// Load tickets when authenticated
const loadTickets = async () => {
if (isAuthenticated.value && currentUser.value) {
await refresh()
}
}
return {
// State
tickets: sortedTickets,
paidTickets,
pendingTickets,
registeredTickets,
unregisteredTickets,
isLoading,
error,
// Actions
refresh: loadTickets,
}
}

View file

@ -1,4 +1,4 @@
import type { Event, EventsApiError } from '../types/event' import type { Event, EventsApiError, Ticket } from '../types/event'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { lnbitsAPI } from './lnbits' import { lnbitsAPI } from './lnbits'
@ -131,4 +131,32 @@ export async function checkPaymentStatus(eventId: string, paymentHash: string):
console.error('Error checking payment status:', error) console.error('Error checking payment status:', error)
throw error throw error
} }
}
export async function fetchUserTickets(userId: string): Promise<Ticket[]> {
try {
const response = await fetch(
`${API_BASE_URL}/events/api/v1/tickets/user/${userId}`,
{
headers: {
'accept': 'application/json',
'X-API-KEY': API_KEY,
'Authorization': `Bearer ${lnbitsAPI.getAccessToken()}`,
},
}
)
if (!response.ok) {
const error: ApiError = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch user tickets'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error fetching user tickets:', error)
throw error
}
} }

View file

@ -14,6 +14,19 @@ export interface Event {
banner: string | null 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 { export interface EventsApiError {
detail: Array<{ detail: Array<{
loc: [string, number] loc: [string, number]

269
src/pages/MyTickets.vue Normal file
View file

@ -0,0 +1,269 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onMounted } 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, Calendar, CreditCard, CheckCircle, Clock, AlertCircle } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
unregisteredTickets,
isLoading,
error,
refresh
} = useUserTickets()
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' }
}
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-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="ticket in tickets" :key="ticket.id" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground text-sm">Ticket #{{ ticket.id.slice(0, 8) }}</CardTitle>
<Badge :variant="getTicketStatus(ticket).status === 'pending' ? 'secondary' : 'default'">
{{ getTicketStatus(ticket).label }}
</Badge>
</div>
<CardDescription>
Event ID: {{ ticket.event }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(ticket).icon" class="w-4 h-4" :class="getTicketStatus(ticket).color" />
<span class="text-sm">{{ getTicketStatus(ticket).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span class="text-sm">{{ formatDate(ticket.time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span class="text-sm">{{ formatTime(ticket.time) }}</span>
</div>
<div v-if="ticket.reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span class="text-sm">{{ formatDate(ticket.reg_timestamp) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="ticket in paidTickets" :key="ticket.id" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground text-sm">Ticket #{{ ticket.id.slice(0, 8) }}</CardTitle>
<Badge variant="default">
{{ getTicketStatus(ticket).label }}
</Badge>
</div>
<CardDescription>
Event ID: {{ ticket.event }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(ticket).icon" class="w-4 h-4" :class="getTicketStatus(ticket).color" />
<span class="text-sm">{{ getTicketStatus(ticket).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span class="text-sm">{{ formatDate(ticket.time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span class="text-sm">{{ formatTime(ticket.time) }}</span>
</div>
<div v-if="ticket.reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span class="text-sm">{{ formatDate(ticket.reg_timestamp) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="ticket in pendingTickets" :key="ticket.id" class="flex flex-col opacity-75">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground text-sm">Ticket #{{ ticket.id.slice(0, 8) }}</CardTitle>
<Badge variant="secondary">
{{ getTicketStatus(ticket).label }}
</Badge>
</div>
<CardDescription>
Event ID: {{ ticket.event }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(ticket).icon" class="w-4 h-4" :class="getTicketStatus(ticket).color" />
<span class="text-sm">{{ getTicketStatus(ticket).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Created:</span>
<span class="text-sm">{{ formatDate(ticket.time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span class="text-sm">{{ formatTime(ticket.time) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="ticket in registeredTickets" :key="ticket.id" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground text-sm">Ticket #{{ ticket.id.slice(0, 8) }}</CardTitle>
<Badge variant="default">
{{ getTicketStatus(ticket).label }}
</Badge>
</div>
<CardDescription>
Event ID: {{ ticket.event }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(ticket).icon" class="w-4 h-4" :class="getTicketStatus(ticket).color" />
<span class="text-sm">{{ getTicketStatus(ticket).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span class="text-sm">{{ formatDate(ticket.time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span class="text-sm">{{ formatTime(ticket.time) }}</span>
</div>
<div v-if="ticket.reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span class="text-sm">{{ formatDate(ticket.reg_timestamp) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</template>

View file

@ -30,6 +30,15 @@ const router = createRouter({
title: 'Events', title: 'Events',
requiresAuth: true requiresAuth: true
} }
},
{
path: '/my-tickets',
name: 'my-tickets',
component: () => import('@/pages/MyTickets.vue'),
meta: {
title: 'My Tickets',
requiresAuth: true
}
} }
] ]
}) })