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

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