Add cancel RSVP feature (backend DELETE endpoint + frontend UI)
Allows guests to cancel their RSVP via a DELETE endpoint using their guestToken. Frontend shows cancel button in RsvpBar and clears local storage on success. Includes unit tests, integration tests, and E2E spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
<ConfirmDialog
|
||||
:open="!!pendingDeleteToken"
|
||||
title="Remove event?"
|
||||
message="This event will be removed from your list."
|
||||
:message="deleteDialogMessage"
|
||||
confirm-label="Remove"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@@ -42,24 +42,62 @@ 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, removeEvent } = 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 = ''
|
||||
pendingDeleteToken.value = eventToken
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDeleteToken.value) {
|
||||
removeEvent(pendingDeleteToken.value)
|
||||
async function confirmDelete() {
|
||||
if (!pendingDeleteToken.value) return
|
||||
|
||||
const eventToken = pendingDeleteToken.value
|
||||
const rsvp = getRsvp(eventToken)
|
||||
|
||||
if (rsvp) {
|
||||
try {
|
||||
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
token: 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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,32 @@
|
||||
<div class="rsvp-bar">
|
||||
<div class="rsvp-bar__inner">
|
||||
<!-- Status state: already RSVPed -->
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status">
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||||
<div
|
||||
class="rsvp-bar__status"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded"
|
||||
aria-label="You're attending. Tap to show cancel option."
|
||||
@click="expanded = !expanded"
|
||||
@keydown.enter.prevent="expanded = !expanded"
|
||||
@keydown.space.prevent="expanded = !expanded"
|
||||
@keydown.escape="expanded = false"
|
||||
>
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||||
</div>
|
||||
<Transition name="rsvp-bar-cancel">
|
||||
<button
|
||||
v-if="expanded"
|
||||
class="rsvp-bar__cancel"
|
||||
type="button"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Cancel attendance
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- CTA state: no RSVP yet -->
|
||||
@@ -18,13 +41,37 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
hasRsvp?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
open: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
watch(() => props.hasRsvp, () => {
|
||||
expanded.value = false
|
||||
})
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.rsvp-bar__status-wrapper')) {
|
||||
expanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(expanded, (isExpanded) => {
|
||||
if (isExpanded) {
|
||||
document.addEventListener('click', onClickOutside, { capture: true })
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside, { capture: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -73,6 +120,12 @@ defineEmits<{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rsvp-bar__status-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rsvp-bar__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -88,6 +141,13 @@ defineEmits<{
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rsvp-bar__status:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar__check {
|
||||
@@ -101,4 +161,49 @@ defineEmits<{
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(0deg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-card);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #ef5350;
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-active,
|
||||
.rsvp-bar-cancel-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-from,
|
||||
.rsvp-bar-cancel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,10 +78,20 @@ export function useEventStorage() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function removeRsvp(eventToken: string): void {
|
||||
const events = readEvents()
|
||||
const event = events.find((e) => e.eventToken === eventToken)
|
||||
if (event) {
|
||||
delete event.rsvpToken
|
||||
delete event.rsvpName
|
||||
writeEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
function removeEvent(eventToken: string): void {
|
||||
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||
writeEvents(events)
|
||||
}
|
||||
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, removeEvent }
|
||||
}
|
||||
|
||||
@@ -70,11 +70,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel error message -->
|
||||
<div v-if="cancelError" class="detail__cancel-error" role="alert">
|
||||
<p>{{ cancelError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- RSVP bar -->
|
||||
<RsvpBar
|
||||
v-if="state === 'loaded' && event && !isOrganizer"
|
||||
:has-rsvp="!!rsvpName"
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
/>
|
||||
|
||||
<!-- Cancel confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
:open="confirmCancelOpen"
|
||||
title="Cancel attendance?"
|
||||
message="Your attendance will be permanently cancelled."
|
||||
confirm-label="Cancel attendance"
|
||||
cancel-label="Keep"
|
||||
@confirm="handleCancelRsvp"
|
||||
@cancel="confirmCancelOpen = false"
|
||||
/>
|
||||
|
||||
<!-- RSVP bottom sheet -->
|
||||
@@ -113,6 +130,7 @@ import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
import AttendeeList from '@/components/AttendeeList.vue'
|
||||
import BottomSheet from '@/components/BottomSheet.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import RsvpBar from '@/components/RsvpBar.vue'
|
||||
import type { components } from '@/api/schema'
|
||||
|
||||
@@ -120,7 +138,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
|
||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken } = useEventStorage()
|
||||
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
@@ -132,6 +150,8 @@ const nameError = ref('')
|
||||
const submitError = ref('')
|
||||
const submitting = ref(false)
|
||||
const rsvpName = ref<string | undefined>(undefined)
|
||||
const confirmCancelOpen = ref(false)
|
||||
const cancelError = ref('')
|
||||
const isOrganizer = ref(false)
|
||||
const attendeeNames = ref<string[] | null>(null)
|
||||
|
||||
@@ -228,6 +248,37 @@ async function submitRsvp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelRsvp() {
|
||||
confirmCancelOpen.value = false
|
||||
cancelError.value = ''
|
||||
|
||||
const stored = getRsvp(route.params.eventToken as string)
|
||||
if (!stored) return
|
||||
|
||||
try {
|
||||
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
token: route.params.eventToken as string,
|
||||
rsvpToken: stored.rsvpToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 204 || response.status === 404) {
|
||||
removeRsvp(route.params.eventToken as string)
|
||||
rsvpName.value = undefined
|
||||
if (event.value) {
|
||||
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
|
||||
}
|
||||
} else {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
} catch {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||
try {
|
||||
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||||
|
||||
@@ -163,6 +163,7 @@ describe('EventCreateView', () => {
|
||||
getOrganizerToken: vi.fn(),
|
||||
saveRsvp: vi.fn(),
|
||||
getRsvp: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
removeEvent: vi.fn(),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user