Compare commits

..

24 commits

Author SHA1 Message Date
08d7b8622f feat(events): show a live like count on the favorite heart
Display how many people have favorited (liked) an event next to its
heart, updating in real time. A like == the event appearing in someone's
NIP-51 bookmark list (kind 10003) — the same action the heart performs.

New useEventLikes composable keeps ONE batched subscription over every
mounted heart's event coordinate (filtered by #a). It stays open after
EOSE, so a like published by anyone is pushed live and the count ticks
up for everyone — verified end-to-end against a relay (a like from a
fresh key bumped the shown count 2→3 with no reload). The heart also
pops on a live increment (gated past the initial historical-load
window), and the user's own like/un-like reflects instantly via the
optimistic heart state.

Caveat: an un-like by another user only reflects on next load — a
replaceable list that no longer contains the coord stops matching the #a
filter, so the removal isn't pushed. Counts are correct on fresh load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:13:24 +02:00
0e0160fb70 feat(ui): disable enter/exit animations on overlays globally
Reka-ui overlays (dialog, sheet, popover, dropdown, tooltip, …) animate
their open/close via the data-state open/closed attribute. Zero the
animation duration globally in index.css so overlays appear/dismiss
instantly — no fade/zoom/slide. Verified the dialog still mounts and
unmounts correctly (Presence resolves at 0s). Pulse/spin loaders and CSS
transitions (hovers, the favourite heart pop) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:52:34 +02:00
ed1ef89f70 feat(layout): show avatar/login state in the top-right menu trigger
Replace the hamburger icon on the fixed top-right menu button with a
context-aware affordance: the user's avatar when logged in (first
initial when they have no picture), or a login icon when logged out.
Opens the same profile/menu sheet either way; aria-label reflects the
state. overflow-hidden + the avatar filling the round button keeps it
clipped to the chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:25:55 +02:00
4ad776b26f fix(events): even out the Map empty-state icon transparency
The placeholder map icon used text-muted-foreground/30, applying alpha to
the stroke colour so the lucide icon's overlapping fold lines compounded
to a darker shade where they crossed the outline. Use element-level
opacity-30 with the full colour instead, so the whole icon fades
uniformly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:04:12 +02:00
a499461608 fix(events): close calendar popup on route leave
Defensive guard so the date-picker popup can never linger across
navigation (reported: it appeared open on the feed after returning from
an event detail page). Force calendarOpen=false in onBeforeRouteLeave on
both the feed and My Tickets, the two popup hosts. Modals shouldn't
survive a route change regardless of how the close/navigation interleave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:55:33 +02:00
307fccf2ba feat(events): add calendar date visual to My Tickets
Replaces the My-tickets filter that lived on the removed calendar page.
A calendar button opens the date-picker popup with per-day dots over the
user's own events; picking a day filters the ticket list to it (a
removable date chip overrides the upcoming/past toggle while active).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:32:11 +02:00
77b7e42a52 refactor(events): remove the standalone calendar page
The calendar is now a date-picker popup on the feed (and, next, a visual
on My Tickets), so the dedicated /events/calendar page and route are no
longer needed. Delete EventsCalendarPage.vue, drop its route, and update
the stale openCreate comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:29:24 +02:00
72e1ac09cc feat(events): filter the feed by day via the calendar popup
The calendar button now opens the date-picker popup instead of
navigating to a separate page. Picking a day filters the feed to that
day (button highlights while active) and shows a removable date chip
whose ✕ clears ONLY the date selection — distinct from category
clearing, which keeps its own clear in the filter dropdown. Dots in the
popup come from the full (unfiltered) event set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:28:13 +02:00
d7b6740946 feat(events): re-add specific-day filter (calendar popup picks it)
Bring back selectedDate filtering, now driven by the calendar popup
instead of the removed week-strip: when a day is picked it takes
priority over the temporal pills and bypasses the past/upcoming split.
Add a dedicated clearSelectedDate() so the date selection can be cleared
independently of categories (a preset pill also clears it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:14:16 +02:00
25da77e5fe feat(events): add reusable calendar date-picker popup
Add a pickerMode to EventCalendarView (month grid only, emits selectDate
on every day tap, no selected-day events panel) and a new
EventCalendarPopup dialog wrapping it. Picking a day emits the date and
closes. Groundwork for replacing the standalone calendar page with an
on-feed date-picker popup and a My-Tickets event-date visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:12:01 +02:00
1016321522 fix(events): refresh owned tickets after purchase (no reload needed)
After a successful ticket purchase, the feed/calendar "My tickets" filter
and EventCard owned badges didn't update until a full page reload — the
shared useOwnedTickets singleton was never refreshed. Its own docs note a
successful purchase should call refresh(); wire that in at the payment-
confirmed point in useTicketPurchase so every surface reflects the new
ticket immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:50:47 +02:00
a230da8f95 feat(events): put calendar My-tickets filter next to the Back button
The My-tickets filter chip sat on its own row below the Back link,
wasting vertical space. Move it onto the Back-button row (still
left-aligned to clear the fixed top-right hamburger), reclaiming a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:18 +02:00
561ef06ad0 feat(events): compact the calendar month grid
The aspect-square day cells made the month grid ~350px tall on mobile,
pushing the selected-day event list well below the fold. Switch cells to
a fixed h-12 (still a comfortable 48px tap target) and tighten the
section spacing (space-y-4 → space-y-2), so the grid + day's events fit
with less scrolling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:42:12 +02:00
9941bacaf1 fix(events): move My Tickets upcoming/past toggle off the hamburger
The toggle sat in the header's top-right corner, where it collided with
the fixed top-right hamburger menu (the Past button rendered behind it).
Move it to its own left-aligned row above the tabs — same approach the
calendar page uses to clear the hamburger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:12:58 +02:00
0d01c65eaf feat(events): add upcoming/past toggle to My Tickets
My Tickets listed every ticket with no way to separate events that
already happened. Add an Upcoming/Past segmented toggle (defaults to
upcoming) that filters the grouped tickets by their event's date, with
the tab counts (All/Paid/Pending/Registered) derived from the visible
set so badges match what's shown. Events not yet resolved from relays
stay visible under Upcoming until their date is known.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:11:00 +02:00
0b3fc11df2 refactor(events): remove dead specific-date filter logic
With the DatePickerStrip gone, selectedDate/selectDate in useEventFilters
were unreachable — nothing set a specific date anymore (the calendar page
only navigates to event detail). Delete the orphaned DatePickerStrip
component and strip the now-dead date-filter branch, state, and actions.
The feed filters purely by temporal pills + past/upcoming + categories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:06:47 +02:00
7ddd3f5937 feat(events): drop the week-day strip, move calendar next to filters
The feed had two redundant day controls — the DatePickerStrip week strip
and the temporal preset pills — on top of the calendar page. Remove the
week strip (the calendar already covers picking a specific date) and move
the calendar shortcut to the end of the temporal-filter row, next to the
pills. Frees a row and keeps coarse windows (Today/This Week/…) inline
with one-tap access to the full calendar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:03:54 +02:00
666d4c2b83 feat(events): show total ticket capacity alongside remaining
Availability only showed remaining ("N tickets available"), not the total
capacity. Derive total (remaining + sold) on EventTicketInfo and display
"N of M tickets left" on both the event card and the detail page, so
buyers can gauge demand. Unlimited events are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:45:41 +02:00
9823f8d955 feat(events): make favoriting instant (optimistic) + pop animation
The heart took ~1s to fill because toggleBookmark awaited the remote
LNbits signer + relay publish before updating state. Flip local state
optimistically so the heart responds on tap, then sign/publish in the
background and roll back (with an error toast) if it fails. Add a brief
scale pop on the heart when a favorite is added for tactile feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:41:16 +02:00
434d57974f feat(events): add Back button to the calendar page
The calendar page had no way back to the feed except the browser/back
gesture. Add a top-bar ghost Back link (ArrowLeft + "Back" → events feed)
mirroring EventDetailPage's pattern for navigation consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:38:23 +02:00
3dfa69315c feat(events): default calendar selection to today
The month-grid calendar opened with no day selected, so the events panel
below was empty until the user clicked a day. Initialise the selection to
today (currentMonth already starts on the current month) so it opens
showing today's events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:26:26 +02:00
3189adf579 feat(events): include organizer name in event fuzzy search
Search only indexed title/summary/description/location/tags, so typing an
organizer's name found nothing. Organizer display names aren't stored on
the event (they're resolved per-pubkey into the shared ProfileService
cache), so enrich the search corpus with the resolved name read from that
same reactive cache and add it as a Fuse key. Opening the search overlay
warms the cache for any organizers not yet fetched, and the corpus
recomputes as kind-0 metadata arrives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:25:28 +02:00
a5e5136591 fix(activities): let temporal filter pills respect palette radius + shadow
Drop the hardcoded rounded-full on the temporal filter pills so they
inherit each palette's --radius (square under neobrut, gently rounded
elsewhere) and the theme's offset drop-shadow, instead of overriding it.
Add pb-1 pr-1 so neobrut's hard 4px shadow isn't clipped by the
overflow-x-auto scroll box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:23:33 +02:00
ebc5a0f071 feat(profile): move edit affordance into identity card as a pencil
Replace the gear (Settings) button in the profile sheet header with a
pencil button inside the identity card, by the avatar/name row. A pencil
reads as "edit your profile" more intuitively than a header gear, and
co-locating it with the identity it edits is clearer. Opens the same
edit-profile dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:13:46 +02:00
12 changed files with 524 additions and 227 deletions

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Heart } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { useBookmarks } from '../composables/useBookmarks'
import { useEventLikes } from '../composables/useEventLikes'
import { NIP52_KINDS } from '../types/nip52'
const props = defineProps<{
@ -15,13 +16,56 @@ const props = defineProps<{
}>()
const router = useRouter()
const { isAuthenticated } = useAuth()
const { isAuthenticated, user } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks()
const { track, likeCount, setSelf } = useEventLikes()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
function handleToggle() {
// Live count of how many people have favorited (liked) this event.
const count = computed(() => likeCount(coord.value))
// Register this event so its like count is fetched + kept live.
// `ready` gates the live-increment pop so the historical backlog that
// streams in right after mount doesn't make every heart pop on load.
const ready = ref(false)
onMounted(() => {
track(coord.value)
setTimeout(() => (ready.value = true), 1500)
})
// Keep the current user's own contribution in sync with the optimistic
// heart state — instant like/un-like for self, and rollback-safe.
watch(
bookmarked,
(now) => {
const pk = user.value?.pubkey
if (pk) setSelf(coord.value, pk, now)
},
{ immediate: true },
)
// Brief scale "pop" for tactile feedback.
const popping = ref(false)
function pop() {
popping.value = true
setTimeout(() => (popping.value = false), 220)
}
// Pop on the user's own favorite (optimistic, fires immediately on tap).
watch(bookmarked, (now, was) => {
if (now && !was) pop()
})
// Pop when the live count ticks up from someone else liking it too —
// only once past the initial historical-load settle window.
watch(count, (now, was) => {
if (ready.value && now > was) pop()
})
async function handleToggle() {
if (!isAuthenticated.value) {
toast.info('Log in to save favorites', {
action: {
@ -31,18 +75,26 @@ function handleToggle() {
})
return
}
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
if (!ok) {
toast.error("Couldn't save favorite — please try again")
}
}
</script>
<template>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
size="sm"
class="h-8 gap-1 px-2"
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
@click.stop="handleToggle"
>
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
<Heart
class="w-4 h-4 transition-transform duration-200 ease-out"
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
/>
<span v-if="count > 0" class="text-xs font-medium tabular-nums">{{ count }}</span>
</Button>
</template>

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<{
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">

View file

@ -88,9 +88,20 @@ export function useBookmarks() {
/**
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
*
* Updates local state OPTIMISTICALLY so the UI (heart fill) responds
* instantly, then signs + publishes in the background. Signing routes
* through the remote LNbits signer and publishing hits relays, so
* awaiting both before flipping state made the heart lag ~1s. On
* failure the optimistic change is rolled back. Resolves to whether
* the change was persisted.
*/
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
async function toggleBookmark(
eventKind: number,
pubkey: string,
dTag: string,
): Promise<boolean> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords)
@ -101,6 +112,17 @@ export function useBookmarks() {
newCoords.add(coord)
}
// Optimistic flip — preserve the prior state so we can roll back if
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
// the real event is confirmed.
const prevState = state.value
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId }
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt
function rollback() {
state.value = prevState
}
// Build and publish updated bookmark list
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
@ -116,19 +138,25 @@ export function useBookmarks() {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
return
rollback()
return false
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return
if (!relayHub) {
rollback()
return false
}
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
state.value = {
bookmarkedCoords: newCoords,
lastEventId: signedEvent.id,
}
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
;(state.value as any).lastCreatedAt = template.created_at
return true
}
rollback()
return false
}
onMounted(() => {

View file

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

View file

@ -0,0 +1,108 @@
import { reactive } from 'vue'
import type { Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
/**
* Live "like" counts for events. A like == the event appearing in a
* user's NIP-51 bookmark list (kind 10003) — the same action the heart
* performs (and what the Favorites page reads).
*
* One batched subscription covers every event coordinate that a mounted
* heart has registered, filtered by `#a`. It stays open after EOSE, so
* when anyone publishes/updates a bookmark list referencing a tracked
* event the relay pushes it live and the count increments for everyone
* in real time (Alice likes → Bob's count ticks up).
*
* Caveats (inherent to counting replaceable bookmark lists via `#a`):
* - An un-like by ANOTHER user only reflects on next load: their new
* list no longer contains the coord, so it no longer matches the
* filter and we never receive the update. Counts are correct on a
* fresh load (the un-liker is simply absent from the results).
* - The current user's own like/un-like updates instantly via setSelf(),
* driven by the optimistic heart state.
*/
const BOOKMARK_KIND = 10003
// coord ("kind:pubkey:dTag") -> set of pubkeys who bookmarked it.
const authorsByCoord = new Map<string, Set<string>>() // plain map, for dedup
const counts = reactive(new Map<string, number>()) // reactive mirror for the UI
const tracked = new Set<string>()
let unsubscribe: (() => void) | null = null
let resubTimer: ReturnType<typeof setTimeout> | null = null
function setCount(coord: string, pubkey: string, present: boolean) {
let set = authorsByCoord.get(coord)
if (!set) {
set = new Set()
authorsByCoord.set(coord, set)
}
const had = set.has(pubkey)
if (present && !had) {
set.add(pubkey)
counts.set(coord, set.size)
} else if (!present && had) {
set.delete(pubkey)
counts.set(coord, set.size)
}
}
function ingest(event: NostrEvent) {
// A bookmark list references many events via 'a' tags; credit the
// author to every coord we're tracking.
for (const tag of event.tags) {
if (tag[0] === 'a' && tracked.has(tag[1])) setCount(tag[1], event.pubkey, true)
}
}
function resubscribe() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
scheduleResubscribe() // relay hub not registered yet — retry shortly
return
}
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
const coords = [...tracked]
if (coords.length === 0) return
unsubscribe = relayHub.subscribe({
id: 'event-likes-aggregate',
filters: [{ kinds: [BOOKMARK_KIND], '#a': coords }],
onEvent: (event: NostrEvent) => ingest(event),
})
}
function scheduleResubscribe() {
if (resubTimer) clearTimeout(resubTimer)
// Debounced so a burst of mounting hearts results in one (re)subscribe.
resubTimer = setTimeout(resubscribe, 250)
}
export function useEventLikes() {
/** Register an event coordinate so its like count is fetched + kept live. */
function track(coord: string) {
if (!coord || tracked.has(coord)) return
tracked.add(coord)
scheduleResubscribe()
}
/** Reactive like count for a coordinate (0 when none/unknown). */
function likeCount(coord: string): number {
return counts.get(coord) ?? 0
}
/**
* Reflect the current user's own like state immediately (optimistic),
* so their count matches the instant heart fill and their un-like
* decrements right away.
*/
function setSelf(coord: string, pubkey: string, liked: boolean) {
if (!coord || !pubkey) return
setCount(coord, pubkey, liked)
}
return { track, likeCount, setSelf }
}

View file

@ -5,12 +5,19 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// Refresh the shared owned-tickets singleton after a purchase so the
// feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets()
// Async operations
const purchaseOperation = useAsyncOperation()
@ -178,6 +185,12 @@ export function useTicketPurchase() {
clearInterval(checkInterval)
}
// Ticket row(s) now exist — refresh the shared owned-tickets
// state so the feed/calendar My-tickets filter and owned
// badges reflect the purchase immediately (no reload). Runs in
// parallel with QR generation below.
void refreshOwnedTickets()
// Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include
// `ticketId` only. Render one QR per row so each attendee

View file

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

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

View file

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