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 @@
+
+
+
+
+
+ ✓
+ You're attending!
+
+
+
+
+
+
+
+
+
+
+
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
+
+
@@ -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()
})
})