Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
3.9 KiB
Vue
138 lines
3.9 KiB
Vue
<template>
|
|
<div class="event-list">
|
|
<section
|
|
v-for="section in groupedSections"
|
|
:key="section.key"
|
|
:aria-label="section.label"
|
|
class="event-section"
|
|
>
|
|
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
|
|
<div role="list">
|
|
<template v-for="group in section.dateGroups" :key="group.dateKey">
|
|
<DateSubheader v-if="group.showSubheader" :label="group.label" />
|
|
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
|
|
<EventCard
|
|
:event-token="event.eventToken"
|
|
:title="event.title"
|
|
:relative-time="formatRelativeTime(event.dateTime)"
|
|
:is-past="section.key === 'past'"
|
|
:event-role="getRole(event)"
|
|
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
|
|
:date-time="event.dateTime"
|
|
@delete="requestDelete"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
<ConfirmDialog
|
|
:open="!!pendingDeleteToken"
|
|
title="Remove event?"
|
|
:message="deleteDialogMessage"
|
|
confirm-label="Remove"
|
|
cancel-label="Cancel"
|
|
@confirm="confirmDelete"
|
|
@cancel="cancelDelete"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
|
import { useEventGrouping } from '../composables/useEventGrouping'
|
|
import { formatRelativeTime } from '../composables/useRelativeTime'
|
|
import { api } from '../api/client'
|
|
import EventCard from './EventCard.vue'
|
|
import SectionHeader from './SectionHeader.vue'
|
|
import DateSubheader from './DateSubheader.vue'
|
|
import ConfirmDialog from './ConfirmDialog.vue'
|
|
import type { StoredEvent } from '../composables/useEventStorage'
|
|
|
|
const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
|
|
|
|
const pendingDeleteToken = ref<string | null>(null)
|
|
const deleteError = ref('')
|
|
|
|
const deleteDialogMessage = computed(() => {
|
|
if (!pendingDeleteToken.value) return ''
|
|
const rsvp = getRsvp(pendingDeleteToken.value)
|
|
if (rsvp) {
|
|
return 'This event will be removed from your list and your attendance will be cancelled.'
|
|
}
|
|
return 'This event will be removed from your list.'
|
|
})
|
|
|
|
function requestDelete(eventToken: string) {
|
|
deleteError.value = ''
|
|
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
|
|
if (role === 'watcher') {
|
|
removeEvent(eventToken)
|
|
return
|
|
}
|
|
pendingDeleteToken.value = eventToken
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!pendingDeleteToken.value) return
|
|
|
|
const eventToken = pendingDeleteToken.value
|
|
const rsvp = getRsvp(eventToken)
|
|
|
|
if (rsvp) {
|
|
try {
|
|
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
|
params: {
|
|
path: {
|
|
eventToken: eventToken,
|
|
rsvpToken: rsvp.rsvpToken,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (response.status !== 204 && response.status !== 404) {
|
|
deleteError.value = 'Could not cancel attendance. Please try again.'
|
|
pendingDeleteToken.value = null
|
|
return
|
|
}
|
|
} catch {
|
|
deleteError.value = 'Could not cancel attendance. Please try again.'
|
|
pendingDeleteToken.value = null
|
|
return
|
|
}
|
|
}
|
|
|
|
removeEvent(eventToken)
|
|
pendingDeleteToken.value = null
|
|
}
|
|
|
|
function cancelDelete() {
|
|
pendingDeleteToken.value = null
|
|
}
|
|
|
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
|
|
if (event.organizerToken) return 'organizer'
|
|
if (event.rsvpToken) return 'attendee'
|
|
return 'watcher'
|
|
}
|
|
|
|
const groupedSections = computed(() => {
|
|
const valid = getStoredEvents().filter(isValidStoredEvent)
|
|
return useEventGrouping(valid)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.event-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.event-section [role="list"] {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
</style>
|