diff --git a/frontend/e2e/event-rsvp.spec.ts b/frontend/e2e/event-rsvp.spec.ts new file mode 100644 index 0000000..c954e5b --- /dev/null +++ b/frontend/e2e/event-rsvp.spec.ts @@ -0,0 +1,185 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' + +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, + expired: false, +} + +test.describe('US1: RSVP submission flow', () => { + test('submits RSVP, updates attendee count, and persists in localStorage', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + http.post('*/api/events/:token/rsvps', () => { + return HttpResponse.json( + { rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', name: 'Max Mustermann' }, + { status: 201 }, + ) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // CTA is visible + const cta = page.getByRole('button', { name: "I'm attending" }) + await expect(cta).toBeVisible() + + // Open bottom sheet + await cta.click() + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await expect(dialog).toBeVisible() + + // Fill name and submit + await dialog.getByLabel('Your name').fill('Max Mustermann') + await dialog.getByRole('button', { name: 'Count me in' }).click() + + // Bottom sheet closes, status bar appears + await expect(dialog).not.toBeVisible() + await expect(page.getByText("You're attending!")).toBeVisible() + await expect(cta).not.toBeVisible() + + // Attendee count incremented + await expect(page.getByText('13')).toBeVisible() + + // Verify localStorage + const stored = await page.evaluate(() => { + const raw = localStorage.getItem('fete:events') + return raw ? JSON.parse(raw) : null + }) + expect(stored).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', + rsvpName: 'Max Mustermann', + }), + ]), + ) + }) + + test('shows validation error when name is empty', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + await page.getByRole('button', { name: "I'm attending" }).click() + + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await dialog.getByRole('button', { name: 'Count me in' }).click() + + await expect(page.getByText('Please enter your name.')).toBeVisible() + }) + + test('restores RSVP status from localStorage on page load', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + // Pre-seed localStorage + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'fete:events', + JSON.stringify([ + { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + dateTime: '2026-03-15T20:00:00+01:00', + expiryDate: '', + rsvpToken: 'existing-rsvp-token', + rsvpName: 'Anna', + }, + ]), + ) + }) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // Status bar should show, not CTA + await expect(page.getByText("You're attending!")).toBeVisible() + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + }) + + test('shows error when server is unreachable during RSVP', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + http.post('*/api/events/:token/rsvps', () => { + return HttpResponse.json( + { type: 'about:blank', title: 'Bad Request', status: 400 }, + { status: 400, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + 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() + + await expect(page.getByText('Could not submit RSVP. Please try again.')).toBeVisible() + }) + + test('does not show RSVP bar for organizer', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + // Pre-seed localStorage with organizer token + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'fete:events', + JSON.stringify([ + { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + organizerToken: 'org-token-123', + title: 'Summer BBQ', + dateTime: '2026-03-15T20:00:00+01:00', + expiryDate: '', + }, + ]), + ) + }) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // Event content should load + await expect(page.getByText('Summer BBQ')).toBeVisible() + + // But no RSVP bar + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + await expect(page.getByText("You're attending!")).not.toBeVisible() + }) + + test('does not show RSVP bar on expired event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json({ ...fullEvent, expired: true }) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + await expect(page.getByText('This event has ended.')).toBeVisible() + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + }) +}) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 2da8244..e054431 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -192,3 +192,34 @@ textarea.form-field { white-space: nowrap; border: 0; } + +/* Bottom sheet form */ +.sheet-title { + font-size: 1.2rem; + font-weight: 700; + color: var(--color-text); +} + +.rsvp-form { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.rsvp-form__label { + font-size: 0.85rem; + font-weight: 700; + color: var(--color-text); + padding-left: 0.25rem; +} + +.rsvp-form__field-error { + color: #d32f2f; + font-size: 0.875rem; + font-weight: 600; + padding-left: 0.25rem; +} + +.rsvp-form__error { + text-align: center; +} diff --git a/frontend/src/components/BottomSheet.vue b/frontend/src/components/BottomSheet.vue new file mode 100644 index 0000000..c3a0ba6 --- /dev/null +++ b/frontend/src/components/BottomSheet.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/components/RsvpBar.vue b/frontend/src/components/RsvpBar.vue new file mode 100644 index 0000000..76f4d59 --- /dev/null +++ b/frontend/src/components/RsvpBar.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/__tests__/BottomSheet.spec.ts b/frontend/src/components/__tests__/BottomSheet.spec.ts new file mode 100644 index 0000000..383f513 --- /dev/null +++ b/frontend/src/components/__tests__/BottomSheet.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import BottomSheet from '../BottomSheet.vue' + +function mountSheet(open = true) { + return mount(BottomSheet, { + props: { open, label: 'Test Sheet' }, + slots: { default: '

Sheet content

' }, + attachTo: document.body, + }) +} + +describe('BottomSheet', () => { + it('renders slot content when open', () => { + const wrapper = mountSheet(true) + expect(document.body.textContent).toContain('Sheet content') + wrapper.unmount() + }) + + it('does not render content when closed', () => { + const wrapper = mountSheet(false) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + wrapper.unmount() + }) + + it('has aria-modal and aria-label on the dialog', () => { + const wrapper = mountSheet(true) + const dialog = document.body.querySelector('[role="dialog"]')! + expect(dialog.getAttribute('aria-modal')).toBe('true') + expect(dialog.getAttribute('aria-label')).toBe('Test Sheet') + wrapper.unmount() + }) + + it('emits close when backdrop is clicked', async () => { + const wrapper = mountSheet(true) + const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement + await backdrop.click() + // Vue test utils tracks emitted events on the wrapper + expect(wrapper.emitted('close')).toBeTruthy() + wrapper.unmount() + }) + + it('emits close on Escape key', async () => { + const wrapper = mountSheet(true) + const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement + backdrop.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('close')).toBeTruthy() + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/__tests__/RsvpBar.spec.ts b/frontend/src/components/__tests__/RsvpBar.spec.ts new file mode 100644 index 0000000..b9aa1d9 --- /dev/null +++ b/frontend/src/components/__tests__/RsvpBar.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import RsvpBar from '../RsvpBar.vue' + +describe('RsvpBar', () => { + it('renders CTA button when hasRsvp is false', () => { + const wrapper = mount(RsvpBar) + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) + expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending") + expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) + }) + + it('renders status text when hasRsvp is true', () => { + const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) + 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) + }) + + it('emits open when CTA button is clicked', async () => { + const wrapper = mount(RsvpBar) + await wrapper.find('.rsvp-bar__cta').trigger('click') + expect(wrapper.emitted('open')).toHaveLength(1) + }) + + it('does not render CTA button when hasRsvp is true', () => { + const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) + expect(wrapper.find('button').exists()).toBe(false) + }) +}) diff --git a/frontend/src/composables/__tests__/useEventStorage.spec.ts b/frontend/src/composables/__tests__/useEventStorage.spec.ts index 98518a2..3077c5f 100644 --- a/frontend/src/composables/__tests__/useEventStorage.spec.ts +++ b/frontend/src/composables/__tests__/useEventStorage.spec.ts @@ -116,4 +116,52 @@ describe('useEventStorage', () => { expect(events).toHaveLength(1) expect(events[0]!.title).toBe('New Title') }) + + it('saves and retrieves RSVP for an existing event', () => { + const { saveCreatedEvent, saveRsvp, getRsvp } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'abc-123', + title: 'Birthday', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00') + + const rsvp = getRsvp('abc-123') + expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-1', rsvpName: 'Max' }) + }) + + it('saves RSVP for a new event (not previously stored)', () => { + const { saveRsvp, getRsvp, getStoredEvents } = useEventStorage() + + saveRsvp('new-event', 'rsvp-token-2', 'Anna', 'Party', '2026-08-01T18:00:00+02:00') + + const rsvp = getRsvp('new-event') + expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-2', rsvpName: 'Anna' }) + + const events = getStoredEvents() + expect(events).toHaveLength(1) + expect(events[0]!.eventToken).toBe('new-event') + expect(events[0]!.title).toBe('Party') + }) + + it('returns undefined RSVP for event without RSVP', () => { + const { saveCreatedEvent, getRsvp } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'abc-123', + title: 'Test', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + expect(getRsvp('abc-123')).toBeUndefined() + }) + + it('returns undefined RSVP for unknown event', () => { + const { getRsvp } = useEventStorage() + expect(getRsvp('unknown')).toBeUndefined() + }) }) diff --git a/frontend/src/composables/useEventStorage.ts b/frontend/src/composables/useEventStorage.ts index e5e062f..8acbdc1 100644 --- a/frontend/src/composables/useEventStorage.ts +++ b/frontend/src/composables/useEventStorage.ts @@ -4,6 +4,8 @@ export interface StoredEvent { title: string dateTime: string expiryDate: string + rsvpToken?: string + rsvpName?: string } const STORAGE_KEY = 'fete:events' @@ -37,5 +39,25 @@ export function useEventStorage() { return event?.organizerToken } - return { saveCreatedEvent, getStoredEvents, getOrganizerToken } + function saveRsvp(eventToken: string, rsvpToken: string, rsvpName: string, title: string, dateTime: string): void { + const events = readEvents() + const existing = events.find((e) => e.eventToken === eventToken) + if (existing) { + existing.rsvpToken = rsvpToken + existing.rsvpName = rsvpName + } else { + events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName }) + } + writeEvents(events) + } + + function getRsvp(eventToken: string): { rsvpToken: string; rsvpName: string } | undefined { + const event = readEvents().find((e) => e.eventToken === eventToken) + if (event?.rsvpToken && event?.rsvpName) { + return { rsvpToken: event.rsvpToken, rsvpName: event.rsvpName } + } + return undefined + } + + return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp } } diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index b946f98..812c0c7 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -54,6 +54,38 @@

Something went wrong.

+ + + + + + +

RSVP

+
+
+ + + {{ nameError }} +
+ + +
+
@@ -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('loading') const event = ref(null) +// RSVP state +const sheetOpen = ref(false) +const nameInput = ref('') +const nameError = ref('') +const submitError = ref('') +const submitting = ref(false) +const rsvpName = ref(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) diff --git a/frontend/src/views/__tests__/EventCreateView.spec.ts b/frontend/src/views/__tests__/EventCreateView.spec.ts index 447de76..441a2a1 100644 --- a/frontend/src/views/__tests__/EventCreateView.spec.ts +++ b/frontend/src/views/__tests__/EventCreateView.spec.ts @@ -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({ diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts index fddc8dd..2a1f30e 100644 --- a/frontend/src/views/__tests__/EventDetailView.spec.ts +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -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() }) })