Add RSVP frontend: bottom sheet form, RsvpBar, and localStorage persistence

Introduces BottomSheet and RsvpBar components, integrates the RSVP
submission flow into EventDetailView, extends useEventStorage with
saveRsvp/getRsvp, and adds unit tests plus an E2E spec for the RSVP
workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 12:47:53 +01:00
parent d9136481d8
commit be1c5062a2
11 changed files with 856 additions and 42 deletions

View File

@@ -54,6 +54,38 @@
<p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<RsvpBar
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
:has-rsvp="!!rsvpName"
@open="sheetOpen = true"
/>
<!-- RSVP bottom sheet -->
<BottomSheet :open="sheetOpen" label="RSVP" @close="sheetOpen = false">
<h2 class="sheet-title">RSVP</h2>
<form class="rsvp-form" @submit.prevent="submitRsvp" novalidate>
<div class="form-group">
<label class="rsvp-form__label" for="rsvp-name">Your name</label>
<input
id="rsvp-name"
v-model.trim="nameInput"
class="form-field"
type="text"
placeholder="e.g. Max Mustermann"
maxlength="100"
required
@input="nameError = ''"
/>
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : "Count me in" }}
</button>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form>
</BottomSheet>
</main>
</template>
@@ -61,15 +93,29 @@
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import BottomSheet from '@/components/BottomSheet.vue'
import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema'
type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
// RSVP state
const sheetOpen = ref(false)
const nameInput = ref('')
const nameError = ref('')
const submitError = ref('')
const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined)
const isOrganizer = ref(false)
const formattedDateTime = computed(() => {
if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, {
@@ -95,11 +141,68 @@ async function fetchEvent() {
event.value = data!
state.value = 'loaded'
// Check if current user is the organizer
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
// Restore RSVP status from localStorage
const stored = getRsvp(event.value.eventToken)
if (stored) {
rsvpName.value = stored.rsvpName
}
} catch {
state.value = 'error'
}
}
async function submitRsvp() {
nameError.value = ''
submitError.value = ''
if (!nameInput.value) {
nameError.value = 'Please enter your name.'
return
}
if (nameInput.value.length > 100) {
nameError.value = 'Name must be 100 characters or fewer.'
return
}
submitting.value = true
try {
const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { token: route.params.token as string } },
body: { name: nameInput.value },
})
if (error) {
submitError.value = 'Could not submit RSVP. Please try again.'
return
}
// Persist RSVP in localStorage
saveRsvp(
event.value!.eventToken,
data!.rsvpToken,
data!.name,
event.value!.title,
event.value!.dateTime,
)
// Update UI
rsvpName.value = data!.name
event.value!.attendeeCount += 1
sheetOpen.value = false
nameInput.value = ''
} catch {
submitError.value = 'Could not submit RSVP. Please try again.'
} finally {
submitting.value = false
}
}
onMounted(fetchEvent)
</script>

View File

@@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({
saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
})),
}))
@@ -165,6 +167,8 @@ describe('EventCreateView', () => {
saveCreatedEvent: mockSave,
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
})
vi.mocked(api.POST).mockResolvedValueOnce({

View File

@@ -7,9 +7,24 @@ import { api } from '@/api/client'
vi.mock('@/api/client', () => ({
api: {
GET: vi.fn(),
POST: vi.fn(),
},
}))
const mockSaveRsvp = vi.fn()
const mockGetRsvp = vi.fn()
const mockGetOrganizerToken = vi.fn()
vi.mock('@/composables/useEventStorage', () => ({
useEventStorage: vi.fn(() => ({
saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []),
getOrganizerToken: mockGetOrganizerToken,
saveRsvp: mockSaveRsvp,
getRsvp: mockGetRsvp,
})),
}))
function createTestRouter(_token?: string) {
return createRouter({
history: createMemoryHistory(),
@@ -26,6 +41,7 @@ async function mountWithToken(token = 'test-token') {
await router.isReady()
return mount(EventDetailView, {
global: { plugins: [router] },
attachTo: document.body,
})
}
@@ -40,12 +56,22 @@ const fullEvent = {
expired: false,
}
function mockLoadedEvent(eventOverrides = {}) {
vi.mocked(api.GET).mockResolvedValue({
data: { ...fullEvent, ...eventOverrides },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
}
beforeEach(() => {
vi.restoreAllMocks()
mockGetRsvp.mockReturnValue(undefined)
mockGetOrganizerToken.mockReturnValue(undefined)
})
describe('EventDetailView', () => {
// T014: Loading state
// Loading state
it('renders skeleton shimmer placeholders while loading', async () => {
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
@@ -53,15 +79,12 @@ describe('EventDetailView', () => {
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
wrapper.unmount()
})
// T013: Loaded state — all fields
// Loaded state — all fields
it('renders all event fields when loaded', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
@@ -71,37 +94,25 @@ describe('EventDetailView', () => {
expect(wrapper.text()).toContain('Central Park, NYC')
expect(wrapper.text()).toContain('12')
expect(wrapper.text()).toContain('Europe/Berlin')
wrapper.unmount()
})
// T013: Loaded state — locale-formatted date/time
// Loaded state — locale-formatted date/time
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const dateField = wrapper.findAll('.detail__value')[0]!
expect(dateField.text()).toContain('(Europe/Berlin)')
// The formatted date part is locale-dependent but should contain the year
expect(dateField.text()).toContain('2026')
wrapper.unmount()
})
// T013: Loaded state — optional fields absent
// Loaded state — optional fields absent
it('does not render description and location when absent', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: {
...fullEvent,
description: undefined,
location: undefined,
attendeeCount: 0,
},
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
const wrapper = await mountWithToken()
await flushPromises()
@@ -109,38 +120,33 @@ describe('EventDetailView', () => {
expect(wrapper.text()).not.toContain('Description')
expect(wrapper.text()).not.toContain('Location')
expect(wrapper.text()).toContain('0')
wrapper.unmount()
})
// T020 (US2): Expired state
// Expired state
it('renders "event has ended" banner when expired', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: { ...fullEvent, expired: true },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).toContain('This event has ended.')
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
wrapper.unmount()
})
// T020 (US2): No expired banner when not expired
// No expired banner when not expired
it('does not render expired banner when event is active', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
wrapper.unmount()
})
// T023 (US4): Not found state
// Not found state
it('renders "event not found" when API returns 404', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: undefined,
@@ -152,11 +158,11 @@ describe('EventDetailView', () => {
await flushPromises()
expect(wrapper.text()).toContain('Event not found.')
// No event data in DOM
expect(wrapper.find('.detail__title').exists()).toBe(false)
wrapper.unmount()
})
// T027: Server error + retry
// Server error + retry
it('renders error state with retry button on server error', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: undefined,
@@ -169,9 +175,10 @@ describe('EventDetailView', () => {
expect(wrapper.text()).toContain('Something went wrong.')
expect(wrapper.find('button').text()).toBe('Retry')
wrapper.unmount()
})
// T027: Retry button re-fetches
// Retry button re-fetches
it('retry button triggers a new fetch', async () => {
vi.mocked(api.GET)
.mockResolvedValueOnce({
@@ -194,5 +201,167 @@ describe('EventDetailView', () => {
await flushPromises()
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
wrapper.unmount()
})
// RSVP bar
it('shows RSVP CTA bar on active event', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
wrapper.unmount()
})
it('does not show RSVP bar for organizer', async () => {
mockGetOrganizerToken.mockReturnValue('org-token-123')
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount()
})
it('does not show RSVP bar on expired event', async () => {
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
wrapper.unmount()
})
it('shows RSVP status bar when localStorage has RSVP', async () => {
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount()
})
// RSVP form submission
it('opens bottom sheet when CTA is clicked', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
wrapper.unmount()
})
it('shows validation error when submitting empty name', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
// Form is inside Teleport — find via document.body
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
expect(document.body.querySelector('.rsvp-form__field-error')?.textContent).toBe('Please enter your name.')
expect(vi.mocked(api.POST)).not.toHaveBeenCalled()
wrapper.unmount()
})
it('submits RSVP, saves to storage, and shows status', async () => {
mockLoadedEvent()
vi.mocked(api.POST).mockResolvedValue({
data: { rsvpToken: 'rsvp-token-1', name: 'Max' },
error: undefined,
response: new Response(null, { status: 201 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
// Open sheet
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
// Fill name via Teleported input
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
input.value = 'Max'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
// Submit form
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
// Verify API call
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
params: { path: { token: 'test-token' } },
body: { name: 'Max' },
})
// Verify storage
expect(mockSaveRsvp).toHaveBeenCalledWith(
'abc-123',
'rsvp-token-1',
'Max',
'Summer BBQ',
'2026-03-15T20:00:00+01:00',
)
// Verify UI switched to status
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
// Verify attendee count incremented
expect(wrapper.text()).toContain('13')
wrapper.unmount()
})
it('shows error when RSVP submission fails', async () => {
mockLoadedEvent()
vi.mocked(api.POST).mockResolvedValue({
data: undefined,
error: { type: 'about:blank', title: 'Bad Request', status: 400 },
response: new Response(null, { status: 400 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
input.value = 'Max'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
wrapper.unmount()
})
})