Merge pull request 'feat(events): calendar date-picker popup, remove calendar page, My Tickets filtering' (#110) from feat/events-calendar-popup into dev

Reviewed-on: #110
This commit is contained in:
padreug 2026-06-17 08:36:13 +00:00
commit 9810b11cc5
8 changed files with 308 additions and 212 deletions

View file

@ -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>

View 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>

View file

@ -12,6 +12,10 @@ import type { Event } from '../types/event'
const props = defineProps<{ const props = defineProps<{
events: Event[] 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<{ const emit = defineEmits<{
@ -68,13 +72,21 @@ function getDotCount(date: Date): number {
return Math.min(getEventsForDay(date).length, 3) 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(() => { const selectedDayEvents = computed(() => {
if (!selectedDay.value) return [] if (!selectedDay.value) return []
return getEventsForDay(selectedDay.value) return getEventsForDay(selectedDay.value)
}) })
function selectDay(date: Date) { 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)) { if (selectedDay.value && isSameDay(selectedDay.value, date)) {
selectedDay.value = null selectedDay.value = null
} else { } else {
@ -95,7 +107,7 @@ function nextMonth() {
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-2">
<!-- Month navigation --> <!-- Month navigation -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
@ -123,7 +135,7 @@ function nextMonth() {
<button <button
v-for="date in calendarDays" v-for="date in calendarDays"
:key="date.toISOString()" :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="{ :class="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth), 'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay), 'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
@ -145,8 +157,9 @@ function nextMonth() {
</button> </button>
</div> </div>
<!-- Selected day events --> <!-- Selected day events (hidden in picker mode the popup just
<div v-if="selectedDay" class="border-t pt-4 space-y-2"> 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"> <h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }} {{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<span v-if="selectedDayEvents.length > 0" class="ml-1"> <span v-if="selectedDayEvents.length > 0" class="ml-1">

View file

@ -1,7 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { import {
startOfDay, endOfDay, startOfWeek, endOfWeek, startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays, isSameDay, startOfMonth, endOfMonth, addDays,
} from 'date-fns' } from 'date-fns'
import type { Event } from '../types/event' import type { Event } from '../types/event'
import type { EventCategory } from '../types/category' 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. // tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<EventCategory[]>([]) 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 selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false) const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false) const onlyHosting = ref(false)
@ -36,10 +39,10 @@ export function useEventFilters() {
function applyFilters(events: Event[]): Event[] { function applyFilters(events: Event[]): Event[] {
let result = events 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) { 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 dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => { result = result.filter(a => {
@ -47,8 +50,9 @@ export function useEventFilters() {
return a.startDate <= dayEnd && eventEnd >= dayStart return a.startDate <= dayEnd && eventEnd >= dayStart
}) })
} else { } else {
// Temporal filter // Temporal filter (preset pills).
result = applyTemporalFilter(result, temporal.value) result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now", // Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default // mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to // (showPast=false) is upcoming-only; toggling on flips to
@ -80,16 +84,16 @@ export function useEventFilters() {
function setTemporal(value: TemporalFilter) { function setTemporal(value: TemporalFilter) {
temporal.value = value 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) { function selectDate(date: Date) {
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
selectedDate.value = undefined // toggle off
} else {
selectedDate.value = date selectedDate.value = date
temporal.value = 'all' // clear temporal pill when picking a specific date temporal.value = 'all' // a specific day overrides the temporal pill
} }
function clearSelectedDate() {
selectedDate.value = undefined
} }
function toggleCategory(category: EventCategory) { function toggleCategory(category: EventCategory) {
@ -150,6 +154,7 @@ export function useEventFilters() {
applyFilters, applyFilters,
setTemporal, setTemporal,
selectDate, selectDate,
clearSelectedDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,

View file

@ -33,15 +33,6 @@ export const eventsModule = createModulePlugin({
requiresAuth: false, requiresAuth: false,
}, },
}, },
{
path: '/events/calendar',
name: 'events-calendar',
component: () => import('./views/EventsCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{ {
path: '/events/map', path: '/events/map',
name: 'events-map', name: 'events-map',

View file

@ -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>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -8,7 +8,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } 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 brandAppLogoUrl from '@brand-app-logo?url'
import brandAppBannerUrl from '@brand-app-banner?url' import brandAppBannerUrl from '@brand-app-banner?url'
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts // 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 EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.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 EventList from '../components/EventList.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event' import type { Event } from '../types/event'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale()
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const { const {
events, events,
allEvents,
isLoading, isLoading,
error, error,
temporal, temporal,
selectedCategories, selectedCategories,
hasActiveFilters,
selectedDate, selectedDate,
hasActiveFilters,
showPast, showPast,
onlyHosting, onlyHosting,
selectDate,
setTemporal, setTemporal,
selectDate,
clearSelectedDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
togglePast, togglePast,
@ -54,6 +59,14 @@ const {
} = useEvents() } = useEvents()
const filtersOpen = ref(false) 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 // Badge count on the Filters trigger so the user can see at a glance
// that hidden toggles (categories) are currently active even when the // 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 } }) router.push({ name: 'event-detail', params: { id: event.id } })
} }
// Create-activity CTA in the Hosting view. Calendar-tab page lives // Create-activity CTA in the Hosting view. Replaces the old bottom-nav
// on /events/calendar; the icon button at the end of the date // Create entry; shown only while the Hosting filter is active.
// strip is the only entry point now that the bottom-nav Calendar
// tab is gone.
function openCreate() { function openCreate() {
eventsStore.editingEvent = null eventsStore.editingEvent = null
eventsStore.showCreateDialog = true eventsStore.showCreateDialog = true
} }
function openCalendar() { function onSelectDate(date: Date) {
router.push('/events/calendar') // 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> </script>
<template> <template>
@ -118,28 +137,6 @@ function openCalendar() {
/> />
</div> </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 <!-- Filters trigger + Clear-all stay stationary in a left-aligned
column; only the temporal pills scroll horizontally. The column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when categories are active) Filters icon (with a count badge when categories are active)
@ -186,6 +183,19 @@ function openCalendar() {
@toggle-past="togglePast" @toggle-past="togglePast"
/> />
</div> </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> </div>
<CollapsibleContent class="mt-3"> <CollapsibleContent class="mt-3">
<CategoryFilterBar <CategoryFilterBar
@ -196,6 +206,23 @@ function openCalendar() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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 <!-- Create-activity CTA shown when the Hosting bottom-nav tab is
active. Replaces the dedicated Create entry that used to live active. Replaces the dedicated Create entry that used to live
in the bottom nav; lives here so it shows up exactly when the in the bottom nav; lives here so it shows up exactly when the
@ -223,5 +250,15 @@ function openCalendar() {
:compact="onlyHosting" :compact="onlyHosting"
@select="handleSelectEvent" @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> </div>
</template> </template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets' import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events' 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 { ScrollArea } from '@/components/ui/scroll-area'
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 { format } from 'date-fns' import { format, startOfDay, endOfDay } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' 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 { isAuthenticated, userDisplay } = useAuth()
const { dateLocale } = useDateLocale()
const { const {
tickets, tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets, groupedTickets,
isLoading, isLoading,
error, error,
@ -40,6 +41,78 @@ function eventShortLabel(eventId: string): string {
return `Event: ${eventId.slice(0, 8)}` 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 qrCodes = ref<Record<string, string>>({})
const currentTicketIndex = ref<Record<string, number>>({}) const currentTicketIndex = ref<Record<string, number>>({})
@ -178,19 +251,71 @@ onMounted(async () => {
</div> </div>
<div v-else-if="tickets.length > 0"> <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"> <Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4"> <TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger> <TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger> <TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger> <TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger> <TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
</TabsList> </TabsList>
<!-- All Tickets Tab --> <!-- All Tickets Tab -->
<TabsContent value="all"> <TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col"> {{ 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> <CardHeader>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<CardTitle class="text-foreground min-w-0 flex-1"> <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 --> <!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
<TabsContent value="paid"> <TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4"> <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"> <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> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -313,9 +438,9 @@ onMounted(async () => {
<TabsContent value="pending"> <TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4"> <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"> <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> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -338,9 +463,9 @@ onMounted(async () => {
<TabsContent value="registered"> <TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4"> <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"> <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> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -362,5 +487,15 @@ onMounted(async () => {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </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> </div>
</template> </template>