Compare commits

..

26 commits

Author SHA1 Message Date
b6d1626951 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 08:36:27 +00:00
3d37b7393d 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 08:36:27 +00:00
4f4452057a 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 08:36:27 +00:00
9810b11cc5 Merge pull request 'feat(events): calendar date-picker popup, remove calendar page, My Tickets filtering' (#110) from feat/events-calendar-popup into dev
Reviewed-on: #110
2026-06-17 08:36:13 +00:00
9d98f3fdf2 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-17 08:35:28 +00:00
76af245192 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-17 08:35:28 +00:00
e54938ef06 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-17 08:35:28 +00:00
5753e34499 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-17 08:35:28 +00:00
b665e3de07 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-17 08:35:28 +00:00
f7642db611 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-17 08:35:28 +00:00
8c9a8ab945 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-17 08:35:28 +00:00
3d1b888307 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-17 08:35:28 +00:00
c53bc525ef 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-17 08:35:28 +00:00
1433a2039b 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-17 08:35:28 +00:00
084bfc52eb 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-17 08:35:28 +00:00
cdc8158e1b 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-17 08:35:28 +00:00
52e9d11ea9 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-17 08:35:28 +00:00
32a7389d0b 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-17 08:35:28 +00:00
c9fc3652bb Merge pull request 'feat(ui): cosmetic tweaks — profile pencil, pills, search, ticket count, map icon, avatar trigger, no overlay animations' (#105) from feat/ui-tweaks-2 into dev
Reviewed-on: #105
2026-06-17 08:35:08 +00:00
1249d33aac 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 10:25:30 +02:00
8a0f40910a 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-17 10:25:30 +02:00
1c004df99c 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-17 10:25:30 +02:00
f20b404d09 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-17 10:25:30 +02:00
79e20f1e07 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-17 10:25:30 +02:00
1f68660783 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-17 10:25:30 +02:00
2e55a45ed6 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-17 10:25:30 +02:00
21 changed files with 407 additions and 243 deletions

View file

@ -221,3 +221,16 @@
background: transparent; 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"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next' import { Check, Copy, Home, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -104,19 +104,7 @@ async function onLogout() {
<template> <template>
<SheetHeader> <SheetHeader>
<div class="flex items-center justify-between gap-2"> <SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<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"> <SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }} {{ t('common.nav.profileDescription') }}
</SheetDescription> </SheetDescription>
@ -125,8 +113,8 @@ async function onLogout() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<!-- Identity card (logged in) read-only summary. Editing happens <!-- Identity card (logged in) summary with an inline edit (pencil)
through the gear button next to the title. --> button that opens the profile form. -->
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4"> <div 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"> <div class="flex items-center gap-3 min-w-0">
<Avatar class="h-12 w-12 shrink-0"> <Avatar class="h-12 w-12 shrink-0">
@ -139,6 +127,15 @@ async function onLogout() {
@{{ user.username }} @{{ user.username }}
</p> </p>
</div> </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> </div>
<!-- Identifier rows: full-width value with a corner-offset "legend" <!-- Identifier rows: full-width value with a corner-offset "legend"
@ -246,8 +243,8 @@ async function onLogout() {
</AlertDialog> </AlertDialog>
</div> </div>
<!-- Edit-profile popup (gear icon) the full form lives here so the <!-- Edit-profile popup (pencil button in the identity card) the full
sheet stays scannable. --> form lives here so the sheet stays scannable. -->
<Dialog v-model:open="editProfileOpen"> <Dialog v-model:open="editProfileOpen">
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden"> <DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader> <DialogHeader>

View file

@ -2,13 +2,16 @@
import { ref, type Component } from 'vue' import { ref, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Menu } from 'lucide-vue-next' import { LogIn } from 'lucide-vue-next'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetTrigger, SheetTrigger,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import ProfileSheetContent from './ProfileSheetContent.vue' import ProfileSheetContent from './ProfileSheetContent.vue'
export interface SidebarNavItem { export interface SidebarNavItem {
@ -35,6 +38,9 @@ const { t } = useI18n()
const router = useRouter() const router = useRouter()
const open = ref(false) const open = ref(false)
const { isAuthenticated } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
function handleClick(item: SidebarNavItem) { function handleClick(item: SidebarNavItem) {
if (item.path) router.push(item.path) if (item.path) router.push(item.path)
item.onClick?.() item.onClick?.()
@ -46,11 +52,17 @@ function handleClick(item: SidebarNavItem) {
<Sheet v-model:open="open"> <Sheet v-model:open="open">
<SheetTrigger as-child> <SheetTrigger as-child>
<button <button
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm" class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center overflow-hidden rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)" style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="t('common.nav.menu')" :aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
> >
<Menu class="w-5 h-5" /> <!-- Logged in: avatar (image, or first initial). Logged out: a
login icon. Opens the same profile/menu sheet either way. -->
<Avatar v-if="isAuthenticated" class="h-full w-full">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar>
<LogIn v-else class="w-5 h-5" />
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden"> <SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">

View file

@ -107,6 +107,7 @@ const messages: LocaleMessages = {
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', ticketsAvailable: '{count} tickets available',
ticketsRemainingOfTotal: '{count} of {total} tickets left',
ticketsOwned: 'You have {count} ticket | You have {count} tickets', ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets', unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket', buyTicket: 'Buy ticket',

View file

@ -107,6 +107,7 @@ const messages: LocaleMessages = {
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: '{count} boletos disponibles',
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos', ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados', unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto', buyTicket: 'Comprar boleto',

View file

@ -107,6 +107,7 @@ const messages: LocaleMessages = {
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: '{count} billets disponibles',
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets', ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités', unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet', buyTicket: 'Acheter un billet',

View file

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

View file

@ -1,73 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useDateLocale } from '../composables/useDateLocale'
const props = defineProps<{
/** Currently selected date (if any) */
selectedDate?: Date
}>()
const emit = defineEmits<{
select: [date: Date]
}>()
const { dateLocale } = useDateLocale()
/** Start of the visible week */
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
const days = computed(() => {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart.value, i))
})
const isToday = (date: Date) => isSameDay(date, new Date())
const isSelected = (date: Date) => props.selectedDate ? isSameDay(date, props.selectedDate) : false
function prevWeek() {
weekStart.value = addDays(weekStart.value, -7)
}
function nextWeek() {
weekStart.value = addDays(weekStart.value, 7)
}
</script>
<template>
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="prevWeek">
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="grid grid-cols-7 flex-1 gap-0.5">
<button
v-for="day in days"
:key="day.toISOString()"
class="flex flex-col items-center py-1.5 rounded-lg transition-colors"
:class="{
'bg-primary text-primary-foreground': isSelected(day),
'bg-muted/50': isToday(day) && !isSelected(day),
'hover:bg-muted': !isSelected(day),
}"
@click="emit('select', day)"
>
<span class="text-[10px] font-medium uppercase leading-none"
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
>
{{ format(day, 'EEEEE', { locale: dateLocale }) }}
</span>
<span class="text-sm font-semibold leading-tight mt-0.5">
{{ format(day, 'd') }}
</span>
</button>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="nextWeek">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import EventCalendarView from './EventCalendarView.vue'
import type { Event } from '../types/event'
// A date-picker popup: the month grid (with per-day event dots) in a
// dialog. Picking a day emits selectDate and closes. Reused by the feed
// (filter to a day) and My Tickets (visualise the user's event dates).
const props = defineProps<{
open: boolean
events: Event[]
title: string
description: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
selectDate: [date: Date]
}>()
const isOpen = computed({
get: () => props.open,
set: (v) => emit('update:open', v),
})
function onSelectDate(date: Date) {
emit('selectDate', date)
isOpen.value = false
}
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<EventCalendarView :events="events" picker-mode @select-date="onSelectDate" />
</DialogContent>
</Dialog>
</template>

View file

@ -12,6 +12,10 @@ import type { Event } from '../types/event'
const props = defineProps<{ const props = defineProps<{
events: Event[] events: Event[]
/** When true, render only the month grid for date-picking no
* selected-day events panel and emit selectDate on every day tap
* (used inside the calendar popup). */
pickerMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -68,13 +72,21 @@ function getDotCount(date: Date): number {
return Math.min(getEventsForDay(date).length, 3) return Math.min(getEventsForDay(date).length, 3)
} }
const selectedDay = ref<Date | null>(null) // Default the selection to today so the calendar opens on today's events
// rather than an empty panel (currentMonth already starts on this month).
const selectedDay = ref<Date | null>(new Date())
const selectedDayEvents = computed(() => { const selectedDayEvents = computed(() => {
if (!selectedDay.value) return [] if (!selectedDay.value) return []
return getEventsForDay(selectedDay.value) return getEventsForDay(selectedDay.value)
}) })
function selectDay(date: Date) { function selectDay(date: Date) {
// Picker mode: every tap selects + emits (parent closes the popup).
if (props.pickerMode) {
selectedDay.value = date
emit('selectDate', date)
return
}
if (selectedDay.value && isSameDay(selectedDay.value, date)) { if (selectedDay.value && isSameDay(selectedDay.value, date)) {
selectedDay.value = null selectedDay.value = null
} else { } else {
@ -95,7 +107,7 @@ function nextMonth() {
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-2">
<!-- Month navigation --> <!-- Month navigation -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
@ -123,7 +135,7 @@ function nextMonth() {
<button <button
v-for="date in calendarDays" v-for="date in calendarDays"
:key="date.toISOString()" :key="date.toISOString()"
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors" class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors"
:class="{ :class="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth), 'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay), 'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
@ -145,8 +157,9 @@ function nextMonth() {
</button> </button>
</div> </div>
<!-- Selected day events --> <!-- Selected day events (hidden in picker mode the popup just
<div v-if="selectedDay" class="border-t pt-4 space-y-2"> picks a day and closes). -->
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground"> <h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }} {{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<span v-if="selectedDayEvents.length > 0" class="ml-1"> <span v-if="selectedDayEvents.length > 0" class="ml-1">

View file

@ -245,7 +245,7 @@ const isNonApproved = computed(
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span> </span>
<span v-else-if="event.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }} {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }} {{ t('events.detail.soldOut') }}

View file

@ -7,8 +7,13 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Search, X, MapPin, Calendar } from 'lucide-vue-next' import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch' 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' import type { Event } from '../types/event'
/** Event enriched with its resolved organizer display name for search. */
type SearchableEvent = Event & { organizerName: string }
const props = defineProps<{ const props = defineProps<{
events: Event[] events: Event[]
}>() }>()
@ -22,12 +27,13 @@ const { dateLocale } = useDateLocale()
const isOpen = ref(false) const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null) const inputRef = ref<HTMLInputElement | null>(null)
const searchOptions: FuzzySearchOptions<Event> = { const searchOptions: FuzzySearchOptions<SearchableEvent> = {
fuseOptions: { fuseOptions: {
keys: [ keys: [
{ name: 'title', weight: 0.5 }, { name: 'title', weight: 0.5 },
{ name: 'summary', weight: 0.2 }, { name: 'summary', weight: 0.2 },
{ name: 'description', weight: 0.15 }, { name: 'description', weight: 0.15 },
{ name: 'organizerName', weight: 0.1 },
{ name: 'location', weight: 0.1 }, { name: 'location', weight: 0.1 },
{ name: 'tags', weight: 0.05 }, { name: 'tags', weight: 0.05 },
], ],
@ -39,7 +45,20 @@ const searchOptions: FuzzySearchOptions<Event> = {
resultLimit: 8, resultLimit: 8,
} }
const eventsRef = computed(() => props.events) // Organizer display names aren't stored on the event (they're fetched
// per-pubkey into the shared ProfileService cache). Read the resolved
// name from that same reactive cache so search matches it; the corpus
// recomputes as kind-0 metadata lands.
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
function organizerNameFor(pubkey: string): string {
const p = profileService?.profiles.get(pubkey)
return p?.display_name ?? p?.name ?? ''
}
const searchCorpus = computed<SearchableEvent[]>(() =>
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
)
const { const {
searchQuery, searchQuery,
@ -47,7 +66,7 @@ const {
isSearching, isSearching,
clearSearch, clearSearch,
setSearchQuery, setSearchQuery,
} = useFuzzySearch(eventsRef, searchOptions) } = useFuzzySearch(searchCorpus, searchOptions)
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0) const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0) const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
@ -94,6 +113,18 @@ function handleClickOutside(e: MouseEvent) {
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (open) { if (open) {
document.addEventListener('click', handleClickOutside) 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 { } else {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
} }

View file

@ -26,13 +26,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
</script> </script>
<template> <template>
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;"> <!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
shadow down and to the right) from being clipped at the scroll box's
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
<Button <Button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'" :variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm" size="sm"
class="rounded-full text-xs shrink-0" class="text-xs shrink-0"
@click="emit('update:modelValue', option.value)" @click="emit('update:modelValue', option.value)"
> >
{{ t(option.labelKey) }} {{ t(option.labelKey) }}
@ -45,7 +48,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
<Button <Button
:variant="props.showPast ? 'default' : 'outline'" :variant="props.showPast ? 'default' : 'outline'"
size="sm" size="sm"
class="rounded-full text-xs shrink-0 gap-1.5" class="text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')" @click="emit('toggle-past')"
> >
<History class="w-3 h-3" /> <History class="w-3 h-3" />

View file

@ -1,7 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { import {
startOfDay, endOfDay, startOfWeek, endOfWeek, startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays, isSameDay, startOfMonth, endOfMonth, addDays,
} from 'date-fns' } from 'date-fns'
import type { Event } from '../types/event' import type { Event } from '../types/event'
import type { EventCategory } from '../types/category' import type { EventCategory } from '../types/category'
@ -15,6 +15,9 @@ import { DEFAULT_FILTERS } from '../types/filters'
// tapping Hosting toggled a private ref the page never saw. // tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<EventCategory[]>([]) const selectedCategories = ref<EventCategory[]>([])
// A specific day picked from the calendar popup. When set it takes
// priority over the temporal pills + past/upcoming split (browse any
// single day). Cleared independently of categories.
const selectedDate = ref<Date | undefined>(undefined) const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false) const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false) const onlyHosting = ref(false)
@ -36,10 +39,10 @@ export function useEventFilters() {
function applyFilters(events: Event[]): Event[] { function applyFilters(events: Event[]): Event[] {
let result = events let result = events
// Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse events for any day they choose.
if (selectedDate.value) { if (selectedDate.value) {
// Specific day picked from the calendar popup — takes priority over
// the temporal pills and bypasses the past/upcoming split so any
// day (past or future) can be browsed.
const dayStart = startOfDay(selectedDate.value) const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => { result = result.filter(a => {
@ -47,8 +50,9 @@ export function useEventFilters() {
return a.startDate <= dayEnd && eventEnd >= dayStart return a.startDate <= dayEnd && eventEnd >= dayStart
}) })
} else { } else {
// Temporal filter // Temporal filter (preset pills).
result = applyTemporalFilter(result, temporal.value) result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now", // Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default // mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to // (showPast=false) is upcoming-only; toggling on flips to
@ -80,16 +84,16 @@ export function useEventFilters() {
function setTemporal(value: TemporalFilter) { function setTemporal(value: TemporalFilter) {
temporal.value = value temporal.value = value
selectedDate.value = undefined // clear date pick when using temporal pills selectedDate.value = undefined // a preset pill clears the day pick
} }
function selectDate(date: Date) { function selectDate(date: Date) {
if (selectedDate.value && isSameDay(selectedDate.value, date)) { selectedDate.value = date
selectedDate.value = undefined // toggle off temporal.value = 'all' // a specific day overrides the temporal pill
} else { }
selectedDate.value = date
temporal.value = 'all' // clear temporal pill when picking a specific date function clearSelectedDate() {
} selectedDate.value = undefined
} }
function toggleCategory(category: EventCategory) { function toggleCategory(category: EventCategory) {
@ -150,6 +154,7 @@ export function useEventFilters() {
applyFilters, applyFilters,
setTemporal, setTemporal,
selectDate, selectDate,
clearSelectedDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,

View file

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

View file

@ -78,6 +78,8 @@ export interface EventTicketInfo {
available?: number available?: number
/** Running paid count. */ /** Running paid count. */
sold: number sold: number
/** Total capacity (available + sold). Undefined means unlimited. */
total?: number
/** Whether the organizer enabled fiat checkout. */ /** Whether the organizer enabled fiat checkout. */
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
@ -91,6 +93,9 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
currency: ticket.currency, currency: ticket.currency,
available: ticket.available, available: ticket.available,
sold: ticket.sold, 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, allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency, fiatCurrency: ticket.fiatCurrency,
} }

View file

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

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

@ -34,7 +34,10 @@ onMounted(() => {
<!-- No geotagged events --> <!-- 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"> <div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" /> <!-- opacity-30 on the element (not /30 on the colour) so the icon's
overlapping fold strokes fade uniformly instead of compounding
alpha where they overlap. -->
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground">No geotagged events found</p> <p class="text-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> <p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
</div> </div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -8,7 +8,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next' import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next'
import { format } from 'date-fns'
import brandAppLogoUrl from '@brand-app-logo?url' import brandAppLogoUrl from '@brand-app-logo?url'
import brandAppBannerUrl from '@brand-app-banner?url' import brandAppBannerUrl from '@brand-app-banner?url'
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts // Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
@ -26,26 +27,30 @@ import { useEventsStore } from '../stores/events'
import EventSearchOverlay from '../components/EventSearchOverlay.vue' import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue' import EventCalendarPopup from '../components/EventCalendarPopup.vue'
import EventList from '../components/EventList.vue' import EventList from '../components/EventList.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event' import type { Event } from '../types/event'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale()
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const { const {
events, events,
allEvents,
isLoading, isLoading,
error, error,
temporal, temporal,
selectedCategories, selectedCategories,
hasActiveFilters,
selectedDate, selectedDate,
hasActiveFilters,
showPast, showPast,
onlyHosting, onlyHosting,
selectDate,
setTemporal, setTemporal,
selectDate,
clearSelectedDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
togglePast, togglePast,
@ -54,6 +59,14 @@ const {
} = useEvents() } = useEvents()
const filtersOpen = ref(false) const filtersOpen = ref(false)
const calendarOpen = ref(false)
// Human label for the active day filter, shown as a removable chip.
const selectedDateLabel = computed(() =>
selectedDate.value
? format(selectedDate.value, 'EEE, MMM d', { locale: dateLocale.value })
: '',
)
// Badge count on the Filters trigger so the user can see at a glance // Badge count on the Filters trigger so the user can see at a glance
// that hidden toggles (categories) are currently active even when the // that hidden toggles (categories) are currently active even when the
@ -71,18 +84,24 @@ function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } }) router.push({ name: 'event-detail', params: { id: event.id } })
} }
// Create-activity CTA in the Hosting view. Calendar-tab page lives // Create-activity CTA in the Hosting view. Replaces the old bottom-nav
// on /events/calendar; the icon button at the end of the date // Create entry; shown only while the Hosting filter is active.
// strip is the only entry point now that the bottom-nav Calendar
// tab is gone.
function openCreate() { function openCreate() {
eventsStore.editingEvent = null eventsStore.editingEvent = null
eventsStore.showCreateDialog = true eventsStore.showCreateDialog = true
} }
function openCalendar() { function onSelectDate(date: Date) {
router.push('/events/calendar') // The popup closes itself; just apply the day filter.
selectDate(date)
} }
// Safety: never let the date-picker popup persist across navigation
// e.g. it should not reappear when returning to the feed from an event
// detail page.
onBeforeRouteLeave(() => {
calendarOpen.value = false
})
</script> </script>
<template> <template>
@ -118,28 +137,6 @@ function openCalendar() {
/> />
</div> </div>
<!-- Date picker strip + calendar shortcut. The calendar icon used
to be a bottom-nav tab; it now lives on the right of the week
strip so the tabs row stays focused on the primary views.
Hidden in the Hosting view operators don't need calendar
navigation when they're managing their own roster. -->
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
<DatePickerStrip
class="flex-1 min-w-0"
:selected-date="selectedDate"
@select="selectDate"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:aria-label="t('events.nav.calendar')"
@click="openCalendar"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<!-- Filters trigger + Clear-all stay stationary in a left-aligned <!-- Filters trigger + Clear-all stay stationary in a left-aligned
column; only the temporal pills scroll horizontally. The column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when categories are active) Filters icon (with a count badge when categories are active)
@ -186,6 +183,19 @@ function openCalendar() {
@toggle-past="togglePast" @toggle-past="togglePast"
/> />
</div> </div>
<!-- Calendar shortcut opens the date-picker popup to filter the
feed to a single day. Highlighted while a day filter is
active. -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="{ 'bg-accent text-accent-foreground': selectedDate }"
:aria-label="t('events.nav.calendar')"
@click="calendarOpen = true"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div> </div>
<CollapsibleContent class="mt-3"> <CollapsibleContent class="mt-3">
<CategoryFilterBar <CategoryFilterBar
@ -196,6 +206,23 @@ function openCalendar() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Active day-filter chip removing it clears ONLY the date
selection (categories have their own clear in the filter
dropdown). Shown when a day is picked from the calendar popup. -->
<div v-if="selectedDate" class="mb-3 flex">
<Button
variant="secondary"
size="sm"
class="h-7 gap-1.5"
:aria-label="t('events.filters.clearDate', 'Clear date filter')"
@click="clearSelectedDate"
>
<CalendarDays class="w-3.5 h-3.5" />
{{ selectedDateLabel }}
<X class="w-3.5 h-3.5" />
</Button>
</div>
<!-- Create-activity CTA shown when the Hosting bottom-nav tab is <!-- Create-activity CTA shown when the Hosting bottom-nav tab is
active. Replaces the dedicated Create entry that used to live active. Replaces the dedicated Create entry that used to live
in the bottom nav; lives here so it shows up exactly when the in the bottom nav; lives here so it shows up exactly when the
@ -223,5 +250,15 @@ function openCalendar() {
:compact="onlyHosting" :compact="onlyHosting"
@select="handleSelectEvent" @select="handleSelectEvent"
/> />
<!-- Date-picker popup: month grid with per-day event dots. Picking a
day filters the feed to it and closes. -->
<EventCalendarPopup
v-model:open="calendarOpen"
:events="allEvents"
:title="t('events.nav.calendar', 'Calendar')"
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
@select-date="onSelectDate"
/>
</div> </div>
</template> </template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets' import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
@ -10,15 +10,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns' import { format, startOfDay, endOfDay } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next'
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event'
const { isAuthenticated, userDisplay } = useAuth() const { isAuthenticated, userDisplay } = useAuth()
const { dateLocale } = useDateLocale()
const { const {
tickets, tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets, groupedTickets,
isLoading, isLoading,
error, error,
@ -40,6 +41,78 @@ function eventShortLabel(eventId: string): string {
return `Event: ${eventId.slice(0, 8)}` return `Event: ${eventId.slice(0, 8)}`
} }
// Past/upcoming toggle. Defaults to upcoming. An event whose end (or
// start, if no end) is before now counts as past; events not yet
// resolved from relays are treated as upcoming so their tickets stay
// visible until we know otherwise.
const showPast = ref(false)
function isGroupPast(eventId: string): boolean {
const ev = eventsStore.getEventById(eventId)
if (!ev) return false
const end = ev.endDate ?? ev.startDate
return end < new Date()
}
// Calendar popup: visualise the days the user has events. Picking a day
// filters the ticket list to it (overriding the upcoming/past toggle);
// clearing it returns to the toggle.
const calendarOpen = ref(false)
const selectedDay = ref<Date | null>(null)
// The user's events (resolved from their ticket groups) feeds the
// calendar popup's per-day dots.
const myEvents = computed<Event[]>(() => {
const out: Event[] = []
for (const g of groupedTickets.value) {
const ev = eventsStore.getEventById(g.eventId)
if (ev) out.push(ev)
}
return out
})
const selectedDayLabel = computed(() =>
selectedDay.value
? format(selectedDay.value, 'EEE, MMM d', { locale: dateLocale.value })
: '',
)
function isGroupOnDay(eventId: string, day: Date): boolean {
const ev = eventsStore.getEventById(eventId)
if (!ev) return false
const end = ev.endDate ?? ev.startDate
return ev.startDate <= endOfDay(day) && end >= startOfDay(day)
}
function onSelectDay(date: Date) {
selectedDay.value = date
}
// Don't let the calendar popup persist across navigation.
onBeforeRouteLeave(() => {
calendarOpen.value = false
})
const visibleGroups = computed(() => {
if (selectedDay.value) {
return groupedTickets.value.filter(g => isGroupOnDay(g.eventId, selectedDay.value!))
}
return groupedTickets.value.filter(g => isGroupPast(g.eventId) === showPast.value)
})
// Tab counts derived from the visible (past/upcoming-filtered) groups so
// the badges match what's actually shown.
const visibleCounts = computed(() => {
let all = 0, paid = 0, pending = 0, registered = 0
for (const g of visibleGroups.value) {
all += g.tickets.length
paid += g.paidCount
pending += g.pendingCount
registered += g.registeredCount
}
return { all, paid, pending, registered }
})
const qrCodes = ref<Record<string, string>>({}) const qrCodes = ref<Record<string, string>>({})
const currentTicketIndex = ref<Record<string, number>>({}) const currentTicketIndex = ref<Record<string, number>>({})
@ -178,19 +251,71 @@ onMounted(async () => {
</div> </div>
<div v-else-if="tickets.length > 0"> <div v-else-if="tickets.length > 0">
<!-- Filter row own row, left-aligned so it clears the fixed
top-right hamburger menu. Upcoming/Past toggle by default;
when a day is picked from the calendar it's replaced by a
removable date chip (the day overrides the toggle). The
calendar button opens a popup visualising the user's event
dates. -->
<div class="mb-4 flex items-center gap-2">
<div v-if="!selectedDay" class="inline-flex rounded-md border p-0.5">
<Button
:variant="!showPast ? 'default' : 'ghost'"
size="sm"
class="h-7"
@click="showPast = false"
>
Upcoming
</Button>
<Button
:variant="showPast ? 'default' : 'ghost'"
size="sm"
class="h-7"
@click="showPast = true"
>
Past
</Button>
</div>
<Button
v-else
variant="secondary"
size="sm"
class="h-7 gap-1.5"
aria-label="Clear day filter"
@click="selectedDay = null"
>
<CalendarDays class="w-3.5 h-3.5" />
{{ selectedDayLabel }}
<X class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="{ 'bg-accent text-accent-foreground': selectedDay }"
aria-label="Open calendar"
@click="calendarOpen = true"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<Tabs default-value="all" class="w-full"> <Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4"> <TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger> <TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger> <TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger> <TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger> <TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
</TabsList> </TabsList>
<!-- All Tickets Tab --> <!-- All Tickets Tab -->
<TabsContent value="all"> <TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col"> {{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }}
</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in visibleGroups" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<CardTitle class="text-foreground min-w-0 flex-1"> <CardTitle class="text-foreground min-w-0 flex-1">
@ -288,9 +413,9 @@ onMounted(async () => {
<!-- Paid, Pending, Registered tabs follow the same pattern but filter --> <!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
<TabsContent value="paid"> <TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="paidTickets.length === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div> <div v-if="visibleCounts.paid === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col"> <Card v-for="group in visibleGroups.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -313,9 +438,9 @@ onMounted(async () => {
<TabsContent value="pending"> <TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="pendingTickets.length === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div> <div v-if="visibleCounts.pending === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75"> <Card v-for="group in visibleGroups.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -338,9 +463,9 @@ onMounted(async () => {
<TabsContent value="registered"> <TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="registeredTickets.length === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div> <div v-if="visibleCounts.registered === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col"> <Card v-for="group in visibleGroups.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground min-w-0">
<RouterLink <RouterLink
@ -362,5 +487,15 @@ onMounted(async () => {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<!-- Calendar popup: dots show the days the user has events; picking
one filters the ticket list to that day. -->
<EventCalendarPopup
v-model:open="calendarOpen"
:events="myEvents"
title="Your event dates"
description="Pick a day to see your tickets for it"
@select-date="onSelectDay"
/>
</div> </div>
</template> </template>