feat(events): Add comprehensive events management with dynamic fetching and UI
- Integrate Reka UI Tabs component for event browsing - Create useEvents composable for event data management - Implement events API integration with error handling - Add events page with upcoming and past events sections - Configure environment variables for API connection - Add internationalization support for events navigation
This commit is contained in:
parent
b8868f7971
commit
b8c881dea2
14 changed files with 316 additions and 7 deletions
3
.env
3
.env
|
|
@ -2,3 +2,6 @@
|
|||
VITE_SUPPORT_NPUB=npub1tm42jkmdn54zncjcylp34e85jagmgndr0skw4v0rsg8rucmu7r5swayth3
|
||||
|
||||
VITE_NOSTR_RELAYS=["wss://nostr.atitlan.io"]
|
||||
|
||||
VITE_API_BASE_URL=https://lnbits.ariege.io
|
||||
VITE_API_KEY=8453f8a467ea47cfa1a47b358413dd0a
|
||||
|
|
|
|||
54
package-lock.json
generated
54
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"nostr-tools": "^2.10.4",
|
||||
"pinia": "^2.3.1",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.0.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
|
@ -3299,9 +3300,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz",
|
||||
"integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==",
|
||||
"version": "3.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz",
|
||||
"integrity": "sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -3309,12 +3310,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.11.3.tgz",
|
||||
"integrity": "sha512-BVZ00i5XBucetRj2doVd32jOPtJthvZSVJvx9GL4gSQsyngliSCtzlP1Op7TFrEtmebRKT8QUQE1tRhOQzWecQ==",
|
||||
"version": "3.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.2.tgz",
|
||||
"integrity": "sha512-z4swzjdhzCh95n9dw9lTvw+t3iwSkYRlVkYkra3C9mul/m5fTzHR7KmtkwH4qXMTXGJUbngtC/bz2cHQIHkO8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.11.3"
|
||||
"@tanstack/virtual-core": "3.13.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -6336,6 +6337,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz",
|
||||
"integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
|
@ -6751,6 +6758,39 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.0.2.tgz",
|
||||
"integrity": "sha512-pC2UF6Z+kJF96aJvIErhkSO4DJYIeq9pgvh3pntNqcZb3zFGMzw8h2uny+GnLX2CKiQV54kZNYXxecYIiPMGyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@floating-ui/vue": "^1.1.6",
|
||||
"@internationalized/date": "^3.5.0",
|
||||
"@internationalized/number": "^3.5.0",
|
||||
"@tanstack/vue-virtual": "^3.12.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/shared": "^12.5.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"ohash": "^1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/shared": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
|
||||
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"nostr-tools": "^2.10.4",
|
||||
"pinia": "^2.3.1",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.0.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const isOpen = ref(false)
|
|||
|
||||
const navigation = computed<NavigationItem[]>(() => [
|
||||
{ name: t('nav.home'), href: '/' },
|
||||
{ name: t('nav.events'), href: '/events' },
|
||||
{ name: t('nav.support'), href: '/support' },
|
||||
])
|
||||
|
||||
|
|
|
|||
15
src/components/ui/tabs/Tabs.vue
Normal file
15
src/components/ui/tabs/Tabs.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
25
src/components/ui/tabs/TabsList.vue
Normal file
25
src/components/ui/tabs/TabsList.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TabsList, type TabsListProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
'inline-flex items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
4
src/components/ui/tabs/index.ts
Normal file
4
src/components/ui/tabs/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
44
src/composables/useEvents.ts
Normal file
44
src/composables/useEvents.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { Event } from '@/lib/types/event'
|
||||
import { fetchEvents } from '@/lib/api/events'
|
||||
|
||||
export function useEvents() {
|
||||
const { state: events, isLoading, error, execute: refresh } = useAsyncState(
|
||||
fetchEvents,
|
||||
[] as Event[],
|
||||
{
|
||||
immediate: true,
|
||||
resetOnExecute: false,
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ const messages: LocaleMessages = {
|
|||
directory: 'Directory',
|
||||
faq: 'FAQ',
|
||||
support: 'Support',
|
||||
events: 'Events',
|
||||
login: 'Login',
|
||||
logout: 'Logout'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface LocaleMessages {
|
|||
directory: string
|
||||
faq: string
|
||||
support: string
|
||||
events: string
|
||||
login: string
|
||||
logout: string
|
||||
}
|
||||
|
|
|
|||
28
src/lib/api/events.ts
Normal file
28
src/lib/api/events.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Event, EventsApiError } from '../types/event'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://lnbits'
|
||||
const API_KEY = import.meta.env.VITE_API_KEY
|
||||
|
||||
export async function fetchEvents(allWallets = true): Promise<Event[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/events/api/v1/events?all_wallets=${allWallets}`,
|
||||
{
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'X-API-KEY': API_KEY,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error: EventsApiError = await response.json()
|
||||
throw new Error(error.detail[0]?.msg || 'Failed to fetch events')
|
||||
}
|
||||
|
||||
return await response.json() as Event[]
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
23
src/lib/types/event.ts
Normal file
23
src/lib/types/event.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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 EventsApiError {
|
||||
detail: Array<{
|
||||
loc: [string, number]
|
||||
msg: string
|
||||
type: string
|
||||
}>
|
||||
}
|
||||
115
src/pages/events.vue
Normal file
115
src/pages/events.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<script setup lang="ts">
|
||||
import { useEvents } from '@/composables/useEvents'
|
||||
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 { format } from 'date-fns'
|
||||
|
||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return format(new Date(dateStr), 'PPP')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto py-8 px-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Events</h1>
|
||||
<Button variant="outline" @click="refresh" :disabled="isLoading">
|
||||
<i-heroicons-arrow-path 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">
|
||||
<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>{{ 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>{{ formatDate(event.event_start_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">End Date:</span>
|
||||
<span>{{ formatDate(event.event_end_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tickets Available:</span>
|
||||
<span>{{ event.amount_tickets - event.sold }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Price:</span>
|
||||
<span>{{ event.price_per_ticket }} {{ event.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button class="w-full" :disabled="event.amount_tickets <= event.sold">
|
||||
Buy Ticket
|
||||
</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>{{ 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>{{ formatDate(event.event_start_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">End Date:</span>
|
||||
<span>{{ formatDate(event.event_end_date) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Total Tickets:</span>
|
||||
<span>{{ event.amount_tickets }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tickets Sold:</span>
|
||||
<span>{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -9,6 +9,14 @@ const router = createRouter({
|
|||
name: 'home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
component: () => import('@/pages/events.vue'),
|
||||
meta: {
|
||||
title: 'Events'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue