Compare commits
1 commit
fddc26387e
...
be3ac55f1d
| Author | SHA1 | Date | |
|---|---|---|---|
| be3ac55f1d |
25 changed files with 258 additions and 623 deletions
|
|
@ -221,16 +221,3 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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, Pencil, Zap } from 'lucide-vue-next'
|
import { Check, Copy, Home, LogIn, LogOut, Settings, 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,7 +104,19 @@ async function onLogout() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||||
|
<Button
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0"
|
||||||
|
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||||
|
@click="editProfileOpen = true"
|
||||||
|
>
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<SheetDescription v-if="isAuthenticated">
|
<SheetDescription v-if="isAuthenticated">
|
||||||
{{ t('common.nav.profileDescription') }}
|
{{ t('common.nav.profileDescription') }}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
|
|
@ -113,8 +125,8 @@ async function onLogout() {
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||||
button that opens the profile form. -->
|
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 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">
|
||||||
|
|
@ -127,15 +139,6 @@ 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"
|
||||||
|
|
@ -243,8 +246,8 @@ async function onLogout() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
||||||
form lives here so the sheet stays scannable. -->
|
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>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,13 @@
|
||||||
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 { LogIn } from 'lucide-vue-next'
|
import { Menu } 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 {
|
||||||
|
|
@ -38,9 +35,6 @@ 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?.()
|
||||||
|
|
@ -52,17 +46,11 @@ 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 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)"
|
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
|
<Menu class="w-5 h-5" />
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Heart } from 'lucide-vue-next'
|
import { Heart } from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useBookmarks } from '../composables/useBookmarks'
|
import { useBookmarks } from '../composables/useBookmarks'
|
||||||
import { useEventLikes } from '../composables/useEventLikes'
|
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -16,56 +15,13 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated, user } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||||
const { track, likeCount, setSelf } = useEventLikes()
|
|
||||||
|
|
||||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
|
|
||||||
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
// Live count of how many people have favorited (liked) this event.
|
function handleToggle() {
|
||||||
const count = computed(() => likeCount(coord.value))
|
|
||||||
|
|
||||||
// Register this event so its like count is fetched + kept live.
|
|
||||||
// `ready` gates the live-increment pop so the historical backlog that
|
|
||||||
// streams in right after mount doesn't make every heart pop on load.
|
|
||||||
const ready = ref(false)
|
|
||||||
onMounted(() => {
|
|
||||||
track(coord.value)
|
|
||||||
setTimeout(() => (ready.value = true), 1500)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep the current user's own contribution in sync with the optimistic
|
|
||||||
// heart state — instant like/un-like for self, and rollback-safe.
|
|
||||||
watch(
|
|
||||||
bookmarked,
|
|
||||||
(now) => {
|
|
||||||
const pk = user.value?.pubkey
|
|
||||||
if (pk) setSelf(coord.value, pk, now)
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Brief scale "pop" for tactile feedback.
|
|
||||||
const popping = ref(false)
|
|
||||||
function pop() {
|
|
||||||
popping.value = true
|
|
||||||
setTimeout(() => (popping.value = false), 220)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop on the user's own favorite (optimistic, fires immediately on tap).
|
|
||||||
watch(bookmarked, (now, was) => {
|
|
||||||
if (now && !was) pop()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pop when the live count ticks up from someone else liking it too —
|
|
||||||
// only once past the initial historical-load settle window.
|
|
||||||
watch(count, (now, was) => {
|
|
||||||
if (ready.value && now > was) pop()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to save favorites', {
|
toast.info('Log in to save favorites', {
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -75,26 +31,18 @@ async function handleToggle() {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||||
if (!ok) {
|
|
||||||
toast.error("Couldn't save favorite — please try again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
class="h-8 gap-1 px-2"
|
class="h-8 w-8"
|
||||||
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
||||||
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
|
|
||||||
@click.stop="handleToggle"
|
@click.stop="handleToggle"
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
||||||
class="w-4 h-4 transition-transform duration-200 ease-out"
|
|
||||||
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
|
|
||||||
/>
|
|
||||||
<span v-if="count > 0" class="text-xs font-medium tabular-nums">{{ count }}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
73
src/modules/events/components/DatePickerStrip.vue
Normal file
73
src/modules/events/components/DatePickerStrip.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -12,10 +12,6 @@ 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<{
|
||||||
|
|
@ -72,21 +68,13 @@ function getDotCount(date: Date): number {
|
||||||
return Math.min(getEventsForDay(date).length, 3)
|
return Math.min(getEventsForDay(date).length, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default the selection to today so the calendar opens on today's events
|
const selectedDay = ref<Date | null>(null)
|
||||||
// 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 {
|
||||||
|
|
@ -107,7 +95,7 @@ function nextMonth() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-2">
|
<div class="space-y-4">
|
||||||
<!-- 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">
|
||||||
|
|
@ -135,7 +123,7 @@ function nextMonth() {
|
||||||
<button
|
<button
|
||||||
v-for="date in calendarDays"
|
v-for="date in calendarDays"
|
||||||
:key="date.toISOString()"
|
: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="{
|
: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),
|
||||||
|
|
@ -157,9 +145,8 @@ function nextMonth() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected day events (hidden in picker mode — the popup just
|
<!-- Selected day events -->
|
||||||
picks a day and closes). -->
|
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -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.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</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') }}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,8 @@ 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[]
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -27,13 +22,12 @@ 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<SearchableEvent> = {
|
const searchOptions: FuzzySearchOptions<Event> = {
|
||||||
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 },
|
||||||
],
|
],
|
||||||
|
|
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||||
resultLimit: 8,
|
resultLimit: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organizer display names aren't stored on the event (they're fetched
|
const eventsRef = computed(() => props.events)
|
||||||
// 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,
|
||||||
|
|
@ -66,7 +47,7 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
} = useFuzzySearch(searchCorpus, searchOptions)
|
} = useFuzzySearch(eventsRef, 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)
|
||||||
|
|
@ -113,18 +94,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,13 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
|
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||||
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="text-xs shrink-0"
|
class="rounded-full text-xs shrink-0"
|
||||||
@click="emit('update:modelValue', option.value)"
|
@click="emit('update:modelValue', option.value)"
|
||||||
>
|
>
|
||||||
{{ t(option.labelKey) }}
|
{{ t(option.labelKey) }}
|
||||||
|
|
@ -48,7 +45,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
<Button
|
<Button
|
||||||
:variant="props.showPast ? 'default' : 'outline'"
|
:variant="props.showPast ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-xs shrink-0 gap-1.5"
|
class="rounded-full 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" />
|
||||||
|
|
|
||||||
|
|
@ -88,20 +88,9 @@ export function useBookmarks() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
||||||
*
|
|
||||||
* Updates local state OPTIMISTICALLY so the UI (heart fill) responds
|
|
||||||
* instantly, then signs + publishes in the background. Signing routes
|
|
||||||
* through the remote LNbits signer and publishing hits relays, so
|
|
||||||
* awaiting both before flipping state made the heart lag ~1s. On
|
|
||||||
* failure the optimistic change is rolled back. Resolves to whether
|
|
||||||
* the change was persisted.
|
|
||||||
*/
|
*/
|
||||||
async function toggleBookmark(
|
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
||||||
eventKind: number,
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||||
pubkey: string,
|
|
||||||
dTag: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
|
|
||||||
|
|
||||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||||
|
|
@ -112,17 +101,6 @@ export function useBookmarks() {
|
||||||
newCoords.add(coord)
|
newCoords.add(coord)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic flip — preserve the prior state so we can roll back if
|
|
||||||
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
|
|
||||||
// the real event is confirmed.
|
|
||||||
const prevState = state.value
|
|
||||||
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId }
|
|
||||||
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt
|
|
||||||
|
|
||||||
function rollback() {
|
|
||||||
state.value = prevState
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build and publish updated bookmark list
|
// Build and publish updated bookmark list
|
||||||
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
||||||
|
|
||||||
|
|
@ -138,25 +116,19 @@ export function useBookmarks() {
|
||||||
signedEvent = await signEventViaLnbits(template)
|
signedEvent = await signEventViaLnbits(template)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
||||||
rollback()
|
return
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) {
|
if (!relayHub) return
|
||||||
rollback()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
|
state.value = {
|
||||||
;(state.value as any).lastCreatedAt = template.created_at
|
bookmarkedCoords: newCoords,
|
||||||
return true
|
lastEventId: signedEvent.id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rollback()
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||||
} 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,9 +15,6 @@ 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)
|
||||||
|
|
@ -39,10 +36,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 => {
|
||||||
|
|
@ -50,9 +47,8 @@ export function useEventFilters() {
|
||||||
return a.startDate <= dayEnd && eventEnd >= dayStart
|
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Temporal filter (preset pills).
|
// Temporal filter
|
||||||
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
|
||||||
|
|
@ -84,16 +80,16 @@ export function useEventFilters() {
|
||||||
|
|
||||||
function setTemporal(value: TemporalFilter) {
|
function setTemporal(value: TemporalFilter) {
|
||||||
temporal.value = value
|
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) {
|
function selectDate(date: Date) {
|
||||||
selectedDate.value = date
|
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
|
||||||
temporal.value = 'all' // a specific day overrides the temporal pill
|
selectedDate.value = undefined // toggle off
|
||||||
}
|
} else {
|
||||||
|
selectedDate.value = date
|
||||||
function clearSelectedDate() {
|
temporal.value = 'all' // clear temporal pill when picking a specific date
|
||||||
selectedDate.value = undefined
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategory(category: EventCategory) {
|
function toggleCategory(category: EventCategory) {
|
||||||
|
|
@ -154,7 +150,6 @@ export function useEventFilters() {
|
||||||
applyFilters,
|
applyFilters,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
selectDate,
|
selectDate,
|
||||||
clearSelectedDate,
|
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
toggleOwnedTickets,
|
toggleOwnedTickets,
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { reactive } from 'vue'
|
|
||||||
import type { Event as NostrEvent } from 'nostr-tools'
|
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live "like" counts for events. A like == the event appearing in a
|
|
||||||
* user's NIP-51 bookmark list (kind 10003) — the same action the heart
|
|
||||||
* performs (and what the Favorites page reads).
|
|
||||||
*
|
|
||||||
* One batched subscription covers every event coordinate that a mounted
|
|
||||||
* heart has registered, filtered by `#a`. It stays open after EOSE, so
|
|
||||||
* when anyone publishes/updates a bookmark list referencing a tracked
|
|
||||||
* event the relay pushes it live and the count increments for everyone
|
|
||||||
* in real time (Alice likes → Bob's count ticks up).
|
|
||||||
*
|
|
||||||
* Caveats (inherent to counting replaceable bookmark lists via `#a`):
|
|
||||||
* - An un-like by ANOTHER user only reflects on next load: their new
|
|
||||||
* list no longer contains the coord, so it no longer matches the
|
|
||||||
* filter and we never receive the update. Counts are correct on a
|
|
||||||
* fresh load (the un-liker is simply absent from the results).
|
|
||||||
* - The current user's own like/un-like updates instantly via setSelf(),
|
|
||||||
* driven by the optimistic heart state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BOOKMARK_KIND = 10003
|
|
||||||
|
|
||||||
// coord ("kind:pubkey:dTag") -> set of pubkeys who bookmarked it.
|
|
||||||
const authorsByCoord = new Map<string, Set<string>>() // plain map, for dedup
|
|
||||||
const counts = reactive(new Map<string, number>()) // reactive mirror for the UI
|
|
||||||
const tracked = new Set<string>()
|
|
||||||
|
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
let resubTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function setCount(coord: string, pubkey: string, present: boolean) {
|
|
||||||
let set = authorsByCoord.get(coord)
|
|
||||||
if (!set) {
|
|
||||||
set = new Set()
|
|
||||||
authorsByCoord.set(coord, set)
|
|
||||||
}
|
|
||||||
const had = set.has(pubkey)
|
|
||||||
if (present && !had) {
|
|
||||||
set.add(pubkey)
|
|
||||||
counts.set(coord, set.size)
|
|
||||||
} else if (!present && had) {
|
|
||||||
set.delete(pubkey)
|
|
||||||
counts.set(coord, set.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingest(event: NostrEvent) {
|
|
||||||
// A bookmark list references many events via 'a' tags; credit the
|
|
||||||
// author to every coord we're tracking.
|
|
||||||
for (const tag of event.tags) {
|
|
||||||
if (tag[0] === 'a' && tracked.has(tag[1])) setCount(tag[1], event.pubkey, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resubscribe() {
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
|
||||||
if (!relayHub) {
|
|
||||||
scheduleResubscribe() // relay hub not registered yet — retry shortly
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe()
|
|
||||||
unsubscribe = null
|
|
||||||
}
|
|
||||||
const coords = [...tracked]
|
|
||||||
if (coords.length === 0) return
|
|
||||||
unsubscribe = relayHub.subscribe({
|
|
||||||
id: 'event-likes-aggregate',
|
|
||||||
filters: [{ kinds: [BOOKMARK_KIND], '#a': coords }],
|
|
||||||
onEvent: (event: NostrEvent) => ingest(event),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleResubscribe() {
|
|
||||||
if (resubTimer) clearTimeout(resubTimer)
|
|
||||||
// Debounced so a burst of mounting hearts results in one (re)subscribe.
|
|
||||||
resubTimer = setTimeout(resubscribe, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEventLikes() {
|
|
||||||
/** Register an event coordinate so its like count is fetched + kept live. */
|
|
||||||
function track(coord: string) {
|
|
||||||
if (!coord || tracked.has(coord)) return
|
|
||||||
tracked.add(coord)
|
|
||||||
scheduleResubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reactive like count for a coordinate (0 when none/unknown). */
|
|
||||||
function likeCount(coord: string): number {
|
|
||||||
return counts.get(coord) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reflect the current user's own like state immediately (optimistic),
|
|
||||||
* so their count matches the instant heart fill and their un-like
|
|
||||||
* decrements right away.
|
|
||||||
*/
|
|
||||||
function setSelf(coord: string, pubkey: string, liked: boolean) {
|
|
||||||
if (!coord || !pubkey) return
|
|
||||||
setCount(coord, pubkey, liked)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { track, likeCount, setSelf }
|
|
||||||
}
|
|
||||||
|
|
@ -5,19 +5,12 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { PaymentService } from '@/core/services/PaymentService'
|
import type { PaymentService } from '@/core/services/PaymentService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import { useOwnedTickets } from './useOwnedTickets'
|
|
||||||
|
|
||||||
export function useTicketPurchase() {
|
export function useTicketPurchase() {
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
|
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
|
||||||
// Refresh the shared owned-tickets singleton after a purchase so the
|
|
||||||
// feed/calendar "My tickets" filter and EventCard owned badges update
|
|
||||||
// without a reload — purchase is exactly the "consumer that mutates
|
|
||||||
// the ticket set" useOwnedTickets's docs anticipate.
|
|
||||||
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
|
||||||
|
|
||||||
// Async operations
|
// Async operations
|
||||||
const purchaseOperation = useAsyncOperation()
|
const purchaseOperation = useAsyncOperation()
|
||||||
|
|
||||||
|
|
@ -185,12 +178,6 @@ export function useTicketPurchase() {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket row(s) now exist — refresh the shared owned-tickets
|
|
||||||
// state so the feed/calendar My-tickets filter and owned
|
|
||||||
// badges reflect the purchase immediately (no reload). Runs in
|
|
||||||
// parallel with QR generation below.
|
|
||||||
void refreshOwnedTickets()
|
|
||||||
|
|
||||||
// Multi-ticket purchases come back with `ticketIds` (N rows
|
// Multi-ticket purchases come back with `ticketIds` (N rows
|
||||||
// sharing one invoice). Single-ticket purchases include
|
// sharing one invoice). Single-ticket purchases include
|
||||||
// `ticketId` only. Render one QR per row so each attendee
|
// `ticketId` only. Render one QR per row so each attendee
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,6 @@ 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. */
|
||||||
|
|
@ -93,9 +91,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
61
src/modules/events/views/EventsCalendarPage.vue
Normal file
61
src/modules/events/views/EventsCalendarPage.vue
Normal 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>
|
||||||
|
|
@ -34,10 +34,7 @@ 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">
|
||||||
<!-- opacity-30 on the element (not /30 on the colour) so the icon's
|
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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, onBeforeRouteLeave } from 'vue-router'
|
import { useRouter } 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,8 +8,7 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next'
|
import { SlidersHorizontal, CalendarDays, Plus } 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
|
||||||
|
|
@ -27,30 +26,26 @@ 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 EventCalendarPopup from '../components/EventCalendarPopup.vue'
|
import DatePickerStrip from '../components/DatePickerStrip.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,
|
||||||
selectedDate,
|
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
selectedDate,
|
||||||
showPast,
|
showPast,
|
||||||
onlyHosting,
|
onlyHosting,
|
||||||
setTemporal,
|
|
||||||
selectDate,
|
selectDate,
|
||||||
clearSelectedDate,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
togglePast,
|
togglePast,
|
||||||
|
|
@ -59,14 +54,6 @@ 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
|
||||||
|
|
@ -84,24 +71,18 @@ 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. Replaces the old bottom-nav
|
// Create-activity CTA in the Hosting view. Calendar-tab → page lives
|
||||||
// Create entry; shown only while the Hosting filter is active.
|
// 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() {
|
function openCreate() {
|
||||||
eventsStore.editingEvent = null
|
eventsStore.editingEvent = null
|
||||||
eventsStore.showCreateDialog = true
|
eventsStore.showCreateDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectDate(date: Date) {
|
function openCalendar() {
|
||||||
// The popup closes itself; just apply the day filter.
|
router.push('/events/calendar')
|
||||||
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>
|
||||||
|
|
@ -137,6 +118,28 @@ onBeforeRouteLeave(() => {
|
||||||
/>
|
/>
|
||||||
</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)
|
||||||
|
|
@ -183,19 +186,6 @@ onBeforeRouteLeave(() => {
|
||||||
@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
|
||||||
|
|
@ -206,23 +196,6 @@ onBeforeRouteLeave(() => {
|
||||||
</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
|
||||||
|
|
@ -250,15 +223,5 @@ onBeforeRouteLeave(() => {
|
||||||
: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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
|
import { RouterLink } 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,16 +10,15 @@ 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, startOfDay, endOfDay } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next'
|
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } 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,
|
||||||
|
|
@ -41,78 +40,6 @@ 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>>({})
|
||||||
|
|
||||||
|
|
@ -251,71 +178,19 @@ 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 ({{ visibleCounts.all }})</TabsTrigger>
|
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
|
||||||
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
|
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
|
||||||
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
|
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
|
||||||
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
|
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</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 v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
|
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }}
|
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
|
||||||
</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">
|
||||||
|
|
@ -413,9 +288,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="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">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground min-w-0">
|
<CardTitle class="text-foreground min-w-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|
@ -438,9 +313,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="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">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground min-w-0">
|
<CardTitle class="text-foreground min-w-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|
@ -463,9 +338,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="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">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle class="text-foreground min-w-0">
|
<CardTitle class="text-foreground min-w-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|
@ -487,15 +362,5 @@ 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue