Compare commits
26 commits
373e52dd79
...
b6d1626951
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d1626951 | |||
| 3d37b7393d | |||
| 4f4452057a | |||
| 9810b11cc5 | |||
| 9d98f3fdf2 | |||
| 76af245192 | |||
| e54938ef06 | |||
| 5753e34499 | |||
| b665e3de07 | |||
| f7642db611 | |||
| 8c9a8ab945 | |||
| 3d1b888307 | |||
| c53bc525ef | |||
| 1433a2039b | |||
| 084bfc52eb | |||
| cdc8158e1b | |||
| 52e9d11ea9 | |||
| 32a7389d0b | |||
| c9fc3652bb | |||
| 1249d33aac | |||
| 8a0f40910a | |||
| 1c004df99c | |||
| f20b404d09 | |||
| 79e20f1e07 | |||
| 1f68660783 | |||
| 2e55a45ed6 |
21 changed files with 407 additions and 243 deletions
|
|
@ -221,3 +221,16 @@
|
|||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
|
||||
* popover, dropdown, tooltip, …) app-wide. They animate via the
|
||||
* data-state open/closed attribute; zeroing the duration keeps the final
|
||||
* state but removes the motion (overlays appear/disappear instantly).
|
||||
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
|
||||
* heart pop) are unaffected.
|
||||
*/
|
||||
[data-state='open'],
|
||||
[data-state='closed'] {
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next'
|
||||
import { Check, Copy, Home, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -104,19 +104,7 @@ async function onLogout() {
|
|||
|
||||
<template>
|
||||
<SheetHeader>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||
<Button
|
||||
v-if="isAuthenticated"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||
@click="editProfileOpen = true"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||
<SheetDescription v-if="isAuthenticated">
|
||||
{{ t('common.nav.profileDescription') }}
|
||||
</SheetDescription>
|
||||
|
|
@ -125,8 +113,8 @@ async function onLogout() {
|
|||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||
through the gear button next to the title. -->
|
||||
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
||||
button that opens the profile form. -->
|
||||
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar class="h-12 w-12 shrink-0">
|
||||
|
|
@ -139,6 +127,15 @@ async function onLogout() {
|
|||
@{{ user.username }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
|
||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||
@click="editProfileOpen = true"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||
|
|
@ -246,8 +243,8 @@ async function onLogout() {
|
|||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
||||
sheet stays scannable. -->
|
||||
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
||||
form lives here so the sheet stays scannable. -->
|
||||
<Dialog v-model:open="editProfileOpen">
|
||||
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
import { ref, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
|
|
@ -35,6 +38,9 @@ const { t } = useI18n()
|
|||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||
|
||||
function handleClick(item: SidebarNavItem) {
|
||||
if (item.path) router.push(item.path)
|
||||
item.onClick?.()
|
||||
|
|
@ -46,11 +52,17 @@ function handleClick(item: SidebarNavItem) {
|
|||
<Sheet v-model:open="open">
|
||||
<SheetTrigger as-child>
|
||||
<button
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center overflow-hidden rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||
:aria-label="t('common.nav.menu')"
|
||||
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
<!-- Logged in: avatar (image, or first initial). Logged out: a
|
||||
login icon. Opens the same profile/menu sheet either way. -->
|
||||
<Avatar v-if="isAuthenticated" class="h-full w-full">
|
||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<LogIn v-else class="w-5 h-5" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
|||
when: 'When',
|
||||
tickets: 'Tickets',
|
||||
ticketsAvailable: '{count} tickets available',
|
||||
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||
unlimitedTickets: 'Unlimited tickets',
|
||||
buyTicket: 'Buy ticket',
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
|||
when: 'Cuándo',
|
||||
tickets: 'Boletos',
|
||||
ticketsAvailable: '{count} boletos disponibles',
|
||||
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||
unlimitedTickets: 'Boletos ilimitados',
|
||||
buyTicket: 'Comprar boleto',
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
|||
when: 'Quand',
|
||||
tickets: 'Billets',
|
||||
ticketsAvailable: '{count} billets disponibles',
|
||||
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||
unlimitedTickets: 'Billets illimités',
|
||||
buyTicket: 'Acheter un billet',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface LocaleMessages {
|
|||
when: string
|
||||
tickets: string
|
||||
ticketsAvailable: string
|
||||
ticketsRemainingOfTotal: string
|
||||
ticketsOwned: string
|
||||
unlimitedTickets: string
|
||||
buyTicket: string
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Currently selected date (if any) */
|
||||
selectedDate?: Date
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [date: Date]
|
||||
}>()
|
||||
|
||||
const { dateLocale } = useDateLocale()
|
||||
|
||||
/** Start of the visible week */
|
||||
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||
|
||||
const days = computed(() => {
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(weekStart.value, i))
|
||||
})
|
||||
|
||||
const isToday = (date: Date) => isSameDay(date, new Date())
|
||||
const isSelected = (date: Date) => props.selectedDate ? isSameDay(date, props.selectedDate) : false
|
||||
|
||||
function prevWeek() {
|
||||
weekStart.value = addDays(weekStart.value, -7)
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
weekStart.value = addDays(weekStart.value, 7)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="prevWeek">
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="grid grid-cols-7 flex-1 gap-0.5">
|
||||
<button
|
||||
v-for="day in days"
|
||||
:key="day.toISOString()"
|
||||
class="flex flex-col items-center py-1.5 rounded-lg transition-colors"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground': isSelected(day),
|
||||
'bg-muted/50': isToday(day) && !isSelected(day),
|
||||
'hover:bg-muted': !isSelected(day),
|
||||
}"
|
||||
@click="emit('select', day)"
|
||||
>
|
||||
<span class="text-[10px] font-medium uppercase leading-none"
|
||||
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ format(day, 'EEEEE', { locale: dateLocale }) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold leading-tight mt-0.5">
|
||||
{{ format(day, 'd') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="nextWeek">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
49
src/modules/events/components/EventCalendarPopup.vue
Normal file
49
src/modules/events/components/EventCalendarPopup.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import EventCalendarView from './EventCalendarView.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
// A date-picker popup: the month grid (with per-day event dots) in a
|
||||
// dialog. Picking a day emits selectDate and closes. Reused by the feed
|
||||
// (filter to a day) and My Tickets (visualise the user's event dates).
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
events: Event[]
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
selectDate: [date: Date]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (v) => emit('update:open', v),
|
||||
})
|
||||
|
||||
function onSelectDate(date: Date) {
|
||||
emit('selectDate', date)
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EventCalendarView :events="events" picker-mode @select-date="onSelectDate" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -12,6 +12,10 @@ import type { Event } from '../types/event'
|
|||
|
||||
const props = defineProps<{
|
||||
events: Event[]
|
||||
/** When true, render only the month grid for date-picking — no
|
||||
* selected-day events panel — and emit selectDate on every day tap
|
||||
* (used inside the calendar popup). */
|
||||
pickerMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -68,13 +72,21 @@ function getDotCount(date: Date): number {
|
|||
return Math.min(getEventsForDay(date).length, 3)
|
||||
}
|
||||
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
// Default the selection to today so the calendar opens on today's events
|
||||
// rather than an empty panel (currentMonth already starts on this month).
|
||||
const selectedDay = ref<Date | null>(new Date())
|
||||
const selectedDayEvents = computed(() => {
|
||||
if (!selectedDay.value) return []
|
||||
return getEventsForDay(selectedDay.value)
|
||||
})
|
||||
|
||||
function selectDay(date: Date) {
|
||||
// Picker mode: every tap selects + emits (parent closes the popup).
|
||||
if (props.pickerMode) {
|
||||
selectedDay.value = date
|
||||
emit('selectDate', date)
|
||||
return
|
||||
}
|
||||
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
||||
selectedDay.value = null
|
||||
} else {
|
||||
|
|
@ -95,7 +107,7 @@ function nextMonth() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<!-- Month navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
||||
|
|
@ -123,7 +135,7 @@ function nextMonth() {
|
|||
<button
|
||||
v-for="date in calendarDays"
|
||||
:key="date.toISOString()"
|
||||
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
|
||||
class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors"
|
||||
:class="{
|
||||
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
||||
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
||||
|
|
@ -145,8 +157,9 @@ function nextMonth() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected day events -->
|
||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||
<!-- Selected day events (hidden in picker mode — the popup just
|
||||
picks a day and closes). -->
|
||||
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
|
||||
<h3 class="text-sm font-medium text-muted-foreground">
|
||||
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ const isNonApproved = computed(
|
|||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('events.detail.soldOut') }}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import { Input } from '@/components/ui/input'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
/** Event enriched with its resolved organizer display name for search. */
|
||||
type SearchableEvent = Event & { organizerName: string }
|
||||
|
||||
const props = defineProps<{
|
||||
events: Event[]
|
||||
}>()
|
||||
|
|
@ -22,12 +27,13 @@ const { dateLocale } = useDateLocale()
|
|||
const isOpen = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<Event> = {
|
||||
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'summary', weight: 0.2 },
|
||||
{ name: 'description', weight: 0.15 },
|
||||
{ name: 'organizerName', weight: 0.1 },
|
||||
{ name: 'location', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.05 },
|
||||
],
|
||||
|
|
@ -39,7 +45,20 @@ const searchOptions: FuzzySearchOptions<Event> = {
|
|||
resultLimit: 8,
|
||||
}
|
||||
|
||||
const eventsRef = computed(() => props.events)
|
||||
// Organizer display names aren't stored on the event (they're fetched
|
||||
// per-pubkey into the shared ProfileService cache). Read the resolved
|
||||
// name from that same reactive cache so search matches it; the corpus
|
||||
// recomputes as kind-0 metadata lands.
|
||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
|
||||
function organizerNameFor(pubkey: string): string {
|
||||
const p = profileService?.profiles.get(pubkey)
|
||||
return p?.display_name ?? p?.name ?? ''
|
||||
}
|
||||
|
||||
const searchCorpus = computed<SearchableEvent[]>(() =>
|
||||
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
|
||||
)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
|
|
@ -47,7 +66,7 @@ const {
|
|||
isSearching,
|
||||
clearSearch,
|
||||
setSearchQuery,
|
||||
} = useFuzzySearch(eventsRef, searchOptions)
|
||||
} = useFuzzySearch(searchCorpus, searchOptions)
|
||||
|
||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||
|
|
@ -94,6 +113,18 @@ function handleClickOutside(e: MouseEvent) {
|
|||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// Warm the shared profile cache for every organizer in the current
|
||||
// set so their names become searchable (fetches dedupe in the
|
||||
// service; the corpus reacts as kind-0 metadata arrives).
|
||||
if (profileService) {
|
||||
const seen = new Set<string>()
|
||||
for (const e of props.events) {
|
||||
const pk = e.organizer.pubkey
|
||||
if (seen.has(pk) || profileService.profiles.get(pk)) continue
|
||||
seen.add(pk)
|
||||
void profileService.getProfile(pk)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
|
||||
shadow down and to the right) from being clipped at the scroll box's
|
||||
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||
<Button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full text-xs shrink-0"
|
||||
class="text-xs shrink-0"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
|
|
@ -45,7 +48,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
<Button
|
||||
:variant="props.showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full text-xs shrink-0 gap-1.5"
|
||||
class="text-xs shrink-0 gap-1.5"
|
||||
@click="emit('toggle-past')"
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||
startOfMonth, endOfMonth, addDays,
|
||||
} from 'date-fns'
|
||||
import type { Event } from '../types/event'
|
||||
import type { EventCategory } from '../types/category'
|
||||
|
|
@ -15,6 +15,9 @@ import { DEFAULT_FILTERS } from '../types/filters'
|
|||
// tapping Hosting toggled a private ref the page never saw.
|
||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
// A specific day picked from the calendar popup. When set it takes
|
||||
// priority over the temporal pills + past/upcoming split (browse any
|
||||
// single day). Cleared independently of categories.
|
||||
const selectedDate = ref<Date | undefined>(undefined)
|
||||
const onlyOwnedTickets = ref(false)
|
||||
const onlyHosting = ref(false)
|
||||
|
|
@ -36,10 +39,10 @@ export function useEventFilters() {
|
|||
function applyFilters(events: Event[]): Event[] {
|
||||
let result = events
|
||||
|
||||
// Specific date filter (from DatePickerStrip) takes priority over
|
||||
// temporal. Picking a date also bypasses the past/upcoming split
|
||||
// so the user can browse events for any day they choose.
|
||||
if (selectedDate.value) {
|
||||
// Specific day picked from the calendar popup — takes priority over
|
||||
// the temporal pills and bypasses the past/upcoming split so any
|
||||
// day (past or future) can be browsed.
|
||||
const dayStart = startOfDay(selectedDate.value)
|
||||
const dayEnd = endOfDay(selectedDate.value)
|
||||
result = result.filter(a => {
|
||||
|
|
@ -47,8 +50,9 @@ export function useEventFilters() {
|
|||
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||
})
|
||||
} else {
|
||||
// Temporal filter
|
||||
// Temporal filter (preset pills).
|
||||
result = applyTemporalFilter(result, temporal.value)
|
||||
|
||||
// Past/upcoming split — the chip narrows to one side of "now",
|
||||
// mirroring the "My tickets" / "Hosting" mental model. Default
|
||||
// (showPast=false) is upcoming-only; toggling on flips to
|
||||
|
|
@ -80,16 +84,16 @@ export function useEventFilters() {
|
|||
|
||||
function setTemporal(value: TemporalFilter) {
|
||||
temporal.value = value
|
||||
selectedDate.value = undefined // clear date pick when using temporal pills
|
||||
selectedDate.value = undefined // a preset pill clears the day pick
|
||||
}
|
||||
|
||||
function selectDate(date: Date) {
|
||||
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
|
||||
selectedDate.value = undefined // toggle off
|
||||
} else {
|
||||
selectedDate.value = date
|
||||
temporal.value = 'all' // clear temporal pill when picking a specific date
|
||||
}
|
||||
selectedDate.value = date
|
||||
temporal.value = 'all' // a specific day overrides the temporal pill
|
||||
}
|
||||
|
||||
function clearSelectedDate() {
|
||||
selectedDate.value = undefined
|
||||
}
|
||||
|
||||
function toggleCategory(category: EventCategory) {
|
||||
|
|
@ -150,6 +154,7 @@ export function useEventFilters() {
|
|||
applyFilters,
|
||||
setTemporal,
|
||||
selectDate,
|
||||
clearSelectedDate,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
|
|
|
|||
|
|
@ -33,15 +33,6 @@ export const eventsModule = createModulePlugin({
|
|||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/events/calendar',
|
||||
name: 'events-calendar',
|
||||
component: () => import('./views/EventsCalendarPage.vue'),
|
||||
meta: {
|
||||
title: 'Calendar',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/events/map',
|
||||
name: 'events-map',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export interface EventTicketInfo {
|
|||
available?: number
|
||||
/** Running paid count. */
|
||||
sold: number
|
||||
/** Total capacity (available + sold). Undefined means unlimited. */
|
||||
total?: number
|
||||
/** Whether the organizer enabled fiat checkout. */
|
||||
allowFiat: boolean
|
||||
/** Fiat settle currency when allowFiat is true. */
|
||||
|
|
@ -91,6 +93,9 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
|||
currency: ticket.currency,
|
||||
available: ticket.available,
|
||||
sold: ticket.sold,
|
||||
// Capacity isn't published directly; derive it from remaining + sold.
|
||||
// Undefined `available` means unlimited, so total stays undefined too.
|
||||
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
||||
allowFiat: ticket.allowFiat,
|
||||
fiatCurrency: ticket.fiatCurrency,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ function goToMyTickets() {
|
|||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Ticket } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import EventCalendarView from '../components/EventCalendarView.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { allEvents, subscribe } = useEvents()
|
||||
const { ownedEventIds } = useOwnedTickets()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
// Per-page toggle, intentionally not wired to the feed's
|
||||
// onlyOwnedTickets filter — narrowing the calendar shouldn't also
|
||||
// narrow the feed the user navigates back to.
|
||||
const onlyMine = ref(false)
|
||||
|
||||
const visibleEvents = computed<Event[]>(() => {
|
||||
if (!onlyMine.value) return allEvents.value
|
||||
const owned = ownedEventIds.value
|
||||
return allEvents.value.filter(a => owned.has(a.id))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
|
||||
function handleSelectEvent(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Filter chip: narrows the calendar to events the user has
|
||||
paid tickets for. Hidden when logged out — nothing to own.
|
||||
Left-aligned so it doesn't collide with the fixed top-right
|
||||
hamburger menu. -->
|
||||
<div v-if="isAuthenticated" class="mb-3 flex">
|
||||
<Button
|
||||
:variant="onlyMine ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="onlyMine = !onlyMine"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||
</Button>
|
||||
</div>
|
||||
<EventCalendarView
|
||||
:events="visibleEvents"
|
||||
@select-event="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -34,7 +34,10 @@ onMounted(() => {
|
|||
|
||||
<!-- No geotagged events -->
|
||||
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<!-- opacity-30 on the element (not /30 on the colour) so the icon's
|
||||
overlapping fold strokes fade uniformly instead of compounding
|
||||
alpha where they overlap. -->
|
||||
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<p class="text-muted-foreground">No geotagged events found</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -8,7 +8,8 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
|
||||
import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next'
|
||||
import { format } from 'date-fns'
|
||||
import brandAppLogoUrl from '@brand-app-logo?url'
|
||||
import brandAppBannerUrl from '@brand-app-banner?url'
|
||||
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
|
||||
|
|
@ -26,26 +27,30 @@ import { useEventsStore } from '../stores/events'
|
|||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
|
||||
import EventList from '../components/EventList.vue'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { dateLocale } = useDateLocale()
|
||||
const eventsStore = useEventsStore()
|
||||
|
||||
const {
|
||||
events,
|
||||
allEvents,
|
||||
isLoading,
|
||||
error,
|
||||
temporal,
|
||||
selectedCategories,
|
||||
hasActiveFilters,
|
||||
selectedDate,
|
||||
hasActiveFilters,
|
||||
showPast,
|
||||
onlyHosting,
|
||||
selectDate,
|
||||
setTemporal,
|
||||
selectDate,
|
||||
clearSelectedDate,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
togglePast,
|
||||
|
|
@ -54,6 +59,14 @@ const {
|
|||
} = useEvents()
|
||||
|
||||
const filtersOpen = ref(false)
|
||||
const calendarOpen = ref(false)
|
||||
|
||||
// Human label for the active day filter, shown as a removable chip.
|
||||
const selectedDateLabel = computed(() =>
|
||||
selectedDate.value
|
||||
? format(selectedDate.value, 'EEE, MMM d', { locale: dateLocale.value })
|
||||
: '',
|
||||
)
|
||||
|
||||
// Badge count on the Filters trigger so the user can see at a glance
|
||||
// that hidden toggles (categories) are currently active even when the
|
||||
|
|
@ -71,18 +84,24 @@ function handleSelectEvent(event: Event) {
|
|||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
|
||||
// Create-activity CTA in the Hosting view. Calendar-tab → page lives
|
||||
// on /events/calendar; the icon button at the end of the date
|
||||
// strip is the only entry point now that the bottom-nav Calendar
|
||||
// tab is gone.
|
||||
// Create-activity CTA in the Hosting view. Replaces the old bottom-nav
|
||||
// Create entry; shown only while the Hosting filter is active.
|
||||
function openCreate() {
|
||||
eventsStore.editingEvent = null
|
||||
eventsStore.showCreateDialog = true
|
||||
}
|
||||
|
||||
function openCalendar() {
|
||||
router.push('/events/calendar')
|
||||
function onSelectDate(date: Date) {
|
||||
// The popup closes itself; just apply the day filter.
|
||||
selectDate(date)
|
||||
}
|
||||
|
||||
// Safety: never let the date-picker popup persist across navigation —
|
||||
// e.g. it should not reappear when returning to the feed from an event
|
||||
// detail page.
|
||||
onBeforeRouteLeave(() => {
|
||||
calendarOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -118,28 +137,6 @@ function openCalendar() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
||||
to be a bottom-nav tab; it now lives on the right of the week
|
||||
strip so the tabs row stays focused on the primary views.
|
||||
Hidden in the Hosting view — operators don't need calendar
|
||||
navigation when they're managing their own roster. -->
|
||||
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
|
||||
<DatePickerStrip
|
||||
class="flex-1 min-w-0"
|
||||
:selected-date="selectedDate"
|
||||
@select="selectDate"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:aria-label="t('events.nav.calendar')"
|
||||
@click="openCalendar"
|
||||
>
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||
column; only the temporal pills scroll horizontally. The
|
||||
Filters icon (with a count badge when categories are active)
|
||||
|
|
@ -186,6 +183,19 @@ function openCalendar() {
|
|||
@toggle-past="togglePast"
|
||||
/>
|
||||
</div>
|
||||
<!-- Calendar shortcut — opens the date-picker popup to filter the
|
||||
feed to a single day. Highlighted while a day filter is
|
||||
active. -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="{ 'bg-accent text-accent-foreground': selectedDate }"
|
||||
:aria-label="t('events.nav.calendar')"
|
||||
@click="calendarOpen = true"
|
||||
>
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent class="mt-3">
|
||||
<CategoryFilterBar
|
||||
|
|
@ -196,6 +206,23 @@ function openCalendar() {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Active day-filter chip — removing it clears ONLY the date
|
||||
selection (categories have their own clear in the filter
|
||||
dropdown). Shown when a day is picked from the calendar popup. -->
|
||||
<div v-if="selectedDate" class="mb-3 flex">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
:aria-label="t('events.filters.clearDate', 'Clear date filter')"
|
||||
@click="clearSelectedDate"
|
||||
>
|
||||
<CalendarDays class="w-3.5 h-3.5" />
|
||||
{{ selectedDateLabel }}
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Create-activity CTA — shown when the Hosting bottom-nav tab is
|
||||
active. Replaces the dedicated Create entry that used to live
|
||||
in the bottom nav; lives here so it shows up exactly when the
|
||||
|
|
@ -223,5 +250,15 @@ function openCalendar() {
|
|||
:compact="onlyHosting"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
|
||||
<!-- Date-picker popup: month grid with per-day event dots. Picking a
|
||||
day filters the feed to it and closes. -->
|
||||
<EventCalendarPopup
|
||||
v-model:open="calendarOpen"
|
||||
:events="allEvents"
|
||||
:title="t('events.nav.calendar', 'Calendar')"
|
||||
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
|
||||
@select-date="onSelectDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useUserTickets } from '../composables/useUserTickets'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
|
|
@ -10,15 +10,16 @@ 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'
|
||||
import { format, startOfDay, endOfDay } from 'date-fns'
|
||||
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next'
|
||||
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const { isAuthenticated, userDisplay } = useAuth()
|
||||
const { dateLocale } = useDateLocale()
|
||||
const {
|
||||
tickets,
|
||||
paidTickets,
|
||||
pendingTickets,
|
||||
registeredTickets,
|
||||
groupedTickets,
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -40,6 +41,78 @@ function eventShortLabel(eventId: string): string {
|
|||
return `Event: ${eventId.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
// Past/upcoming toggle. Defaults to upcoming. An event whose end (or
|
||||
// start, if no end) is before now counts as past; events not yet
|
||||
// resolved from relays are treated as upcoming so their tickets stay
|
||||
// visible until we know otherwise.
|
||||
const showPast = ref(false)
|
||||
|
||||
function isGroupPast(eventId: string): boolean {
|
||||
const ev = eventsStore.getEventById(eventId)
|
||||
if (!ev) return false
|
||||
const end = ev.endDate ?? ev.startDate
|
||||
return end < new Date()
|
||||
}
|
||||
|
||||
// Calendar popup: visualise the days the user has events. Picking a day
|
||||
// filters the ticket list to it (overriding the upcoming/past toggle);
|
||||
// clearing it returns to the toggle.
|
||||
const calendarOpen = ref(false)
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
|
||||
// The user's events (resolved from their ticket groups) — feeds the
|
||||
// calendar popup's per-day dots.
|
||||
const myEvents = computed<Event[]>(() => {
|
||||
const out: Event[] = []
|
||||
for (const g of groupedTickets.value) {
|
||||
const ev = eventsStore.getEventById(g.eventId)
|
||||
if (ev) out.push(ev)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const selectedDayLabel = computed(() =>
|
||||
selectedDay.value
|
||||
? format(selectedDay.value, 'EEE, MMM d', { locale: dateLocale.value })
|
||||
: '',
|
||||
)
|
||||
|
||||
function isGroupOnDay(eventId: string, day: Date): boolean {
|
||||
const ev = eventsStore.getEventById(eventId)
|
||||
if (!ev) return false
|
||||
const end = ev.endDate ?? ev.startDate
|
||||
return ev.startDate <= endOfDay(day) && end >= startOfDay(day)
|
||||
}
|
||||
|
||||
function onSelectDay(date: Date) {
|
||||
selectedDay.value = date
|
||||
}
|
||||
|
||||
// Don't let the calendar popup persist across navigation.
|
||||
onBeforeRouteLeave(() => {
|
||||
calendarOpen.value = false
|
||||
})
|
||||
|
||||
const visibleGroups = computed(() => {
|
||||
if (selectedDay.value) {
|
||||
return groupedTickets.value.filter(g => isGroupOnDay(g.eventId, selectedDay.value!))
|
||||
}
|
||||
return groupedTickets.value.filter(g => isGroupPast(g.eventId) === showPast.value)
|
||||
})
|
||||
|
||||
// Tab counts derived from the visible (past/upcoming-filtered) groups so
|
||||
// the badges match what's actually shown.
|
||||
const visibleCounts = computed(() => {
|
||||
let all = 0, paid = 0, pending = 0, registered = 0
|
||||
for (const g of visibleGroups.value) {
|
||||
all += g.tickets.length
|
||||
paid += g.paidCount
|
||||
pending += g.pendingCount
|
||||
registered += g.registeredCount
|
||||
}
|
||||
return { all, paid, pending, registered }
|
||||
})
|
||||
|
||||
const qrCodes = ref<Record<string, string>>({})
|
||||
const currentTicketIndex = ref<Record<string, number>>({})
|
||||
|
||||
|
|
@ -178,19 +251,71 @@ onMounted(async () => {
|
|||
</div>
|
||||
|
||||
<div v-else-if="tickets.length > 0">
|
||||
<!-- Filter row — own row, left-aligned so it clears the fixed
|
||||
top-right hamburger menu. Upcoming/Past toggle by default;
|
||||
when a day is picked from the calendar it's replaced by a
|
||||
removable date chip (the day overrides the toggle). The
|
||||
calendar button opens a popup visualising the user's event
|
||||
dates. -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<div v-if="!selectedDay" class="inline-flex rounded-md border p-0.5">
|
||||
<Button
|
||||
:variant="!showPast ? 'default' : 'ghost'"
|
||||
size="sm"
|
||||
class="h-7"
|
||||
@click="showPast = false"
|
||||
>
|
||||
Upcoming
|
||||
</Button>
|
||||
<Button
|
||||
:variant="showPast ? 'default' : 'ghost'"
|
||||
size="sm"
|
||||
class="h-7"
|
||||
@click="showPast = true"
|
||||
>
|
||||
Past
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
aria-label="Clear day filter"
|
||||
@click="selectedDay = null"
|
||||
>
|
||||
<CalendarDays class="w-3.5 h-3.5" />
|
||||
{{ selectedDayLabel }}
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="{ 'bg-accent text-accent-foreground': selectedDay }"
|
||||
aria-label="Open calendar"
|
||||
@click="calendarOpen = true"
|
||||
>
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger>
|
||||
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
|
||||
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- All Tickets Tab -->
|
||||
<TabsContent value="all">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
|
||||
<div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
{{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }}
|
||||
</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in visibleGroups" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardTitle class="text-foreground min-w-0 flex-1">
|
||||
|
|
@ -288,9 +413,9 @@ onMounted(async () => {
|
|||
<!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
|
||||
<TabsContent value="paid">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="paidTickets.length === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
|
||||
<div v-if="visibleCounts.paid === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
|
|
@ -313,9 +438,9 @@ onMounted(async () => {
|
|||
|
||||
<TabsContent value="pending">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="pendingTickets.length === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
|
||||
<div v-if="visibleCounts.pending === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
|
|
@ -338,9 +463,9 @@ onMounted(async () => {
|
|||
|
||||
<TabsContent value="registered">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="registeredTickets.length === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
|
||||
<div v-if="visibleCounts.registered === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
|
|
@ -362,5 +487,15 @@ onMounted(async () => {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Calendar popup: dots show the days the user has events; picking
|
||||
one filters the ticket list to that day. -->
|
||||
<EventCalendarPopup
|
||||
v-model:open="calendarOpen"
|
||||
:events="myEvents"
|
||||
title="Your event dates"
|
||||
description="Pick a day to see your tickets for it"
|
||||
@select-date="onSelectDay"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue