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:
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { test, expect } from './msw-setup'
|
||||
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||
|
||||
const STORAGE_KEY = 'fete:events'
|
||||
|
||||
const fullEvent = {
|
||||
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
title: 'Summer BBQ',
|
||||
description: 'Bring your own drinks!',
|
||||
dateTime: '2026-03-15T20:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
}
|
||||
|
||||
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
function rsvpSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
rsvpToken,
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US1: Cancel RSVP from Event Detail View', () => {
|
||||
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Status bar visible
|
||||
const statusBar = page.getByRole('button', { name: /You're attending/ })
|
||||
await expect(statusBar).toBeVisible()
|
||||
|
||||
// Cancel button hidden initially
|
||||
await expect(page.getByRole('button', { name: 'Cancel attendance' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('tapping status bar reveals cancel button', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Tap status bar
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
|
||||
// Cancel button appears
|
||||
await expect(page.getByRole('button', { name: 'Cancel attendance' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Expand → Cancel attendance → Confirm in dialog
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
|
||||
// Confirm dialog
|
||||
await expect(page.getByText('Your attendance will be permanently cancelled.')).toBeVisible()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
|
||||
|
||||
// Bar resets to CTA state
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
|
||||
// Attendee count decremented
|
||||
await expect(page.getByText('11 going')).toBeVisible()
|
||||
|
||||
// localStorage cleared
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
expect(event?.rsvpName).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error → error message, state unchanged', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Expand → Cancel → Confirm in dialog
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
|
||||
|
||||
// Error message
|
||||
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
|
||||
|
||||
// Attendee count unchanged
|
||||
await expect(page.getByText('12 going')).toBeVisible()
|
||||
})
|
||||
|
||||
test('re-RSVP after cancel works', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel first
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
|
||||
|
||||
// CTA should be back
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// Re-RSVP
|
||||
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await dialog.getByLabel('Your name').fill('Max')
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
// Status bar returns
|
||||
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||
})
|
||||
|
||||
test('removal of non-RSVP\'d event shows standard dialog', async ({ page }) => {
|
||||
const noRsvp: StoredEvent = {
|
||||
eventToken: 'no-rsvp-token',
|
||||
title: 'No RSVP Event',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
organizerToken: 'org-123',
|
||||
}
|
||||
await page.addInitScript(seedEvents([noRsvp]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove No RSVP Event/ }).click()
|
||||
|
||||
await expect(page.getByText('This event will be removed from your list.')).toBeVisible()
|
||||
await expect(page.getByText('attendance will be cancelled')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event gone
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
|
||||
// localStorage updated
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event still in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('dismiss dialog → no changes', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Event still there
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
|
||||
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel flow
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
await page.locator('.rsvp-bar__cancel').click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
|
||||
|
||||
// Treated as success — CTA returns
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// localStorage cleaned
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -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