Add cancel RSVP feature (backend DELETE endpoint + frontend UI)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped

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:
2026-03-12 17:45:37 +01:00
parent a1855ff8d6
commit 41bb17d5c9
23 changed files with 1371 additions and 12 deletions

View File

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

View File

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