Compare commits

..

1 commit

Author SHA1 Message Date
be3ac55f1d fix(wallet): stop double-deducting balance on outgoing payments
The wallet WebSocket handler assumed LNbits sends the PRE-payment balance
for outgoing payments and subtracted the amount again. LNbits actually
re-fetches the wallet AFTER the payment settles before emitting the
notification (core payments.py `_send_payment_notification_in_background`
→ "fetch balance again" → notifications.py `send_ws_payment_notification`
→ `wallet.balance`), so `wallet_balance` is already POST-payment for both
directions. The extra subtraction double-deducted: 100 sats, send 10 →
balance showed 80, then a refresh (which reads the authoritative
/api/v1/wallet balance) corrected it to 90.

Use `wallet_balance` as-is for all messages, matching the polling
fallback which already trusts the API balance directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:04:02 +02:00
25 changed files with 258 additions and 623 deletions

View file

@ -221,16 +221,3 @@
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;
}

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Copy, Home, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
import { Check, Copy, Home, LogIn, LogOut, Settings, 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,7 +104,19 @@ async function onLogout() {
<template>
<SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<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>
<SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }}
</SheetDescription>
@ -113,8 +125,8 @@ async function onLogout() {
</SheetDescription>
</SheetHeader>
<!-- Identity card (logged in) summary with an inline edit (pencil)
button that opens the profile form. -->
<!-- Identity card (logged in) read-only summary. Editing happens
through the gear button next to the title. -->
<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">
@ -127,15 +139,6 @@ 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"
@ -243,8 +246,8 @@ async function onLogout() {
</AlertDialog>
</div>
<!-- Edit-profile popup (pencil button in the identity card) the full
form lives here so the sheet stays scannable. -->
<!-- Edit-profile popup (gear icon) 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>

View file

@ -2,16 +2,13 @@
import { ref, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { LogIn } from 'lucide-vue-next'
import { Menu } 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 {
@ -38,9 +35,6 @@ 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?.()
@ -52,17 +46,11 @@ 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 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"
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"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
:aria-label="t('common.nav.menu')"
>
<!-- 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" />
<Menu 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">

View file

@ -107,7 +107,6 @@ 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',

View file

@ -107,7 +107,6 @@ 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',

View file

@ -107,7 +107,6 @@ 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',

View file

@ -82,7 +82,6 @@ export interface LocaleMessages {
when: string
tickets: string
ticketsAvailable: string
ticketsRemainingOfTotal: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string

View file

@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { computed } 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<{
@ -16,56 +15,13 @@ const props = defineProps<{
}>()
const router = useRouter()
const { isAuthenticated, user } = useAuth()
const { isAuthenticated } = 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))
// 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() {
function handleToggle() {
if (!isAuthenticated.value) {
toast.info('Log in to save favorites', {
action: {
@ -75,26 +31,18 @@ async function handleToggle() {
})
return
}
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
if (!ok) {
toast.error("Couldn't save favorite — please try again")
}
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
}
</script>
<template>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1 px-2"
size="icon"
class="h-8 w-8"
: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 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>
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
</Button>
</template>

View file

@ -0,0 +1,73 @@
<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

@ -1,49 +0,0 @@
<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,10 +12,6 @@ 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<{
@ -72,21 +68,13 @@ function getDotCount(date: Date): number {
return Math.min(getEventsForDay(date).length, 3)
}
// 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 selectedDay = ref<Date | null>(null)
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 {
@ -107,7 +95,7 @@ function nextMonth() {
</script>
<template>
<div class="space-y-2">
<div class="space-y-4">
<!-- Month navigation -->
<div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
@ -135,7 +123,7 @@ function nextMonth() {
<button
v-for="date in calendarDays"
:key="date.toISOString()"
class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors"
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
:class="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
@ -157,9 +145,8 @@ function nextMonth() {
</button>
</div>
<!-- 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">
<!-- Selected day events -->
<div v-if="selectedDay" 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

@ -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.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }}

View file

@ -7,13 +7,8 @@ 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[]
}>()
@ -27,13 +22,12 @@ const { dateLocale } = useDateLocale()
const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
const searchOptions: FuzzySearchOptions<Event> = {
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 },
],
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
resultLimit: 8,
}
// 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 eventsRef = computed(() => props.events)
const {
searchQuery,
@ -66,7 +47,7 @@ const {
isSearching,
clearSearch,
setSearchQuery,
} = useFuzzySearch(searchCorpus, searchOptions)
} = useFuzzySearch(eventsRef, searchOptions)
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
@ -113,18 +94,6 @@ 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)
}

View file

@ -26,16 +26,13 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
</script>
<template>
<!-- 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;">
<div class="flex gap-2 overflow-x-auto" 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="text-xs shrink-0"
class="rounded-full text-xs shrink-0"
@click="emit('update:modelValue', option.value)"
>
{{ t(option.labelKey) }}
@ -48,7 +45,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
<Button
:variant="props.showPast ? 'default' : 'outline'"
size="sm"
class="text-xs shrink-0 gap-1.5"
class="rounded-full text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')"
>
<History class="w-3 h-3" />

View file

@ -88,20 +88,9 @@ 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,
): Promise<boolean> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords)
@ -112,17 +101,6 @@ 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])
@ -138,25 +116,19 @@ export function useBookmarks() {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
rollback()
return false
return
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
rollback()
return false
}
if (!relayHub) return
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
;(state.value as any).lastCreatedAt = template.created_at
return true
state.value = {
bookmarkedCoords: newCoords,
lastEventId: signedEvent.id,
}
}
rollback()
return false
}
onMounted(() => {

View file

@ -1,7 +1,7 @@
import { ref, computed } from 'vue'
import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays,
startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns'
import type { Event } from '../types/event'
import type { EventCategory } from '../types/category'
@ -15,9 +15,6 @@ 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)
@ -39,10 +36,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 => {
@ -50,9 +47,8 @@ export function useEventFilters() {
return a.startDate <= dayEnd && eventEnd >= dayStart
})
} else {
// Temporal filter (preset pills).
// Temporal filter
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
@ -84,16 +80,16 @@ export function useEventFilters() {
function setTemporal(value: TemporalFilter) {
temporal.value = value
selectedDate.value = undefined // a preset pill clears the day pick
selectedDate.value = undefined // clear date pick when using temporal pills
}
function selectDate(date: Date) {
selectedDate.value = date
temporal.value = 'all' // a specific day overrides the temporal pill
}
function clearSelectedDate() {
selectedDate.value = undefined
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
}
}
function toggleCategory(category: EventCategory) {
@ -154,7 +150,6 @@ export function useEventFilters() {
applyFilters,
setTemporal,
selectDate,
clearSelectedDate,
toggleCategory,
clearCategories,
toggleOwnedTickets,

View file

@ -1,108 +0,0 @@
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,19 +5,12 @@ 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()
@ -185,12 +178,6 @@ 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,6 +33,15 @@ 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

@ -78,8 +78,6 @@ 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. */
@ -93,9 +91,6 @@ 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,
}

View file

@ -347,7 +347,7 @@ function goToMyTickets() {
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span>
</p>
</div>

View file

@ -0,0 +1,61 @@
<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

@ -34,10 +34,7 @@ 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">
<!-- 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" />
<Map class="w-16 h-16 text-muted-foreground/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>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import {
@ -8,8 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next'
import { format } from 'date-fns'
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
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
@ -27,30 +26,26 @@ import { useEventsStore } from '../stores/events'
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
import DatePickerStrip from '../components/DatePickerStrip.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,
selectedDate,
hasActiveFilters,
selectedDate,
showPast,
onlyHosting,
setTemporal,
selectDate,
clearSelectedDate,
setTemporal,
toggleCategory,
clearCategories,
togglePast,
@ -59,14 +54,6 @@ 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
@ -84,24 +71,18 @@ function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
// Create-activity CTA in the Hosting view. Replaces the old bottom-nav
// Create entry; shown only while the Hosting filter is active.
// 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.
function openCreate() {
eventsStore.editingEvent = null
eventsStore.showCreateDialog = true
}
function onSelectDate(date: Date) {
// The popup closes itself; just apply the day filter.
selectDate(date)
function openCalendar() {
router.push('/events/calendar')
}
// 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>
@ -137,6 +118,28 @@ onBeforeRouteLeave(() => {
/>
</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)
@ -183,19 +186,6 @@ onBeforeRouteLeave(() => {
@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
@ -206,23 +196,6 @@ onBeforeRouteLeave(() => {
</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
@ -250,15 +223,5 @@ onBeforeRouteLeave(() => {
: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 { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events'
@ -10,16 +10,15 @@ 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, 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'
import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const { dateLocale } = useDateLocale()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets,
isLoading,
error,
@ -41,78 +40,6 @@ 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>>({})
@ -251,71 +178,19 @@ 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 ({{ visibleCounts.all }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<!-- All Tickets Tab -->
<TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4">
<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">
<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">
<CardHeader>
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-foreground min-w-0 flex-1">
@ -413,9 +288,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="visibleCounts.paid === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
<div v-if="paidTickets.length === 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 visibleGroups.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<CardTitle class="text-foreground min-w-0">
<RouterLink
@ -438,9 +313,9 @@ onMounted(async () => {
<TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleCounts.pending === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
<div v-if="pendingTickets.length === 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 visibleGroups.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader>
<CardTitle class="text-foreground min-w-0">
<RouterLink
@ -463,9 +338,9 @@ onMounted(async () => {
<TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleCounts.registered === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
<div v-if="registeredTickets.length === 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 visibleGroups.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<CardTitle class="text-foreground min-w-0">
<RouterLink
@ -487,15 +362,5 @@ 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>