Compare commits

..

3 commits

Author SHA1 Message Date
373e52dd79 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:25:13 +02:00
75ec28b4dc 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-17 10:25:13 +02:00
c6ed247031 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-17 10:25:13 +02:00
21 changed files with 243 additions and 407 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>
<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

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

@ -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) {
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
selectedDate.value = undefined // toggle off
} else {
selectedDate.value = date
temporal.value = 'all' // a specific day overrides the temporal pill
temporal.value = 'all' // clear temporal pill when picking a specific date
}
function clearSelectedDate() {
selectedDate.value = undefined
}
function toggleCategory(category: EventCategory) {
@ -154,7 +150,6 @@ export function useEventFilters() {
applyFilters,
setTemporal,
selectDate,
clearSelectedDate,
toggleCategory,
clearCategories,
toggleOwnedTickets,

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>