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:
parent
f7450627bc
commit
63d636a8a0
6 changed files with 403 additions and 2 deletions
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
|
||||
|
|
@ -27,6 +27,7 @@ const showProfileDialog = ref(false)
|
|||
const navigation = computed<NavigationItem[]>(() => [
|
||||
{ name: t('nav.home'), href: '/' },
|
||||
{ name: t('nav.events'), href: '/events' },
|
||||
{ name: 'My Tickets', href: '/my-tickets' },
|
||||
{ name: t('nav.support'), href: '/support' },
|
||||
])
|
||||
|
||||
|
|
@ -98,6 +99,10 @@ const handleLogout = async () => {
|
|||
<User class="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="() => router.push('/my-tickets')" class="gap-2">
|
||||
<Ticket class="h-4 w-4" />
|
||||
My Tickets
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleLogout" class="gap-2 text-destructive">
|
||||
<LogOut class="h-4 w-4" />
|
||||
|
|
|
|||
77
src/composables/useUserTickets.ts
Normal file
77
src/composables/useUserTickets.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { lnbitsAPI } from './lnbits'
|
||||
|
||||
|
|
@ -132,3 +132,31 @@ export async function checkPaymentStatus(eventId: string, paymentHash: string):
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,19 @@ export interface Event {
|
|||
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]
|
||||
|
|
|
|||
269
src/pages/MyTickets.vue
Normal file
269
src/pages/MyTickets.vue
Normal 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>
|
||||
|
|
@ -30,6 +30,15 @@ const router = createRouter({
|
|||
title: 'Events',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/my-tickets',
|
||||
name: 'my-tickets',
|
||||
component: () => import('@/pages/MyTickets.vue'),
|
||||
meta: {
|
||||
title: 'My Tickets',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue