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:
185
frontend/e2e/event-rsvp.spec.ts
Normal file
185
frontend/e2e/event-rsvp.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -192,3 +192,34 @@ textarea.form-field {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
96
frontend/src/components/BottomSheet.vue
Normal file
96
frontend/src/components/BottomSheet.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
|
||||||
|
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
|
||||||
|
<div class="sheet__handle" aria-hidden="true" />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sheetEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sheetEl.value,
|
||||||
|
async (el) => {
|
||||||
|
if (el) {
|
||||||
|
await nextTick()
|
||||||
|
const firstInput = el.querySelector<HTMLElement>('input, textarea, button[type="submit"]')
|
||||||
|
if (firstInput) {
|
||||||
|
firstInput.focus()
|
||||||
|
} else {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-active .sheet,
|
||||||
|
.sheet-leave-active .sheet {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from .sheet,
|
||||||
|
.sheet-leave-to .sheet {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
frontend/src/components/RsvpBar.vue
Normal file
75
frontend/src/components/RsvpBar.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- CTA state: no RSVP yet -->
|
||||||
|
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
|
||||||
|
I'm attending
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
hasRsvp?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
open: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rsvp-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: var(--spacing-md) var(--content-padding);
|
||||||
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__check {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
@@ -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: '<p>Sheet content</p>' },
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
30
frontend/src/components/__tests__/RsvpBar.spec.ts
Normal file
30
frontend/src/components/__tests__/RsvpBar.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -116,4 +116,52 @@ describe('useEventStorage', () => {
|
|||||||
expect(events).toHaveLength(1)
|
expect(events).toHaveLength(1)
|
||||||
expect(events[0]!.title).toBe('New Title')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface StoredEvent {
|
|||||||
title: string
|
title: string
|
||||||
dateTime: string
|
dateTime: string
|
||||||
expiryDate: string
|
expiryDate: string
|
||||||
|
rsvpToken?: string
|
||||||
|
rsvpName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'fete:events'
|
const STORAGE_KEY = 'fete:events'
|
||||||
@@ -37,5 +39,25 @@ export function useEventStorage() {
|
|||||||
return event?.organizerToken
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,38 @@
|
|||||||
<p class="detail__message">Something went wrong.</p>
|
<p class="detail__message">Something went wrong.</p>
|
||||||
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,15 +93,29 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
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'
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
type GetEventResponse = components['schemas']['GetEventResponse']
|
type GetEventResponse = components['schemas']['GetEventResponse']
|
||||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
|
||||||
|
|
||||||
const state = ref<State>('loading')
|
const state = ref<State>('loading')
|
||||||
const event = ref<GetEventResponse | null>(null)
|
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(() => {
|
const formattedDateTime = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return ''
|
||||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||||
@@ -95,11 +141,68 @@ async function fetchEvent() {
|
|||||||
|
|
||||||
event.value = data!
|
event.value = data!
|
||||||
state.value = 'loaded'
|
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 {
|
} catch {
|
||||||
state.value = 'error'
|
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)
|
onMounted(fetchEvent)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({
|
|||||||
saveCreatedEvent: vi.fn(),
|
saveCreatedEvent: vi.fn(),
|
||||||
getStoredEvents: vi.fn(() => []),
|
getStoredEvents: vi.fn(() => []),
|
||||||
getOrganizerToken: vi.fn(),
|
getOrganizerToken: vi.fn(),
|
||||||
|
saveRsvp: vi.fn(),
|
||||||
|
getRsvp: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -165,6 +167,8 @@ describe('EventCreateView', () => {
|
|||||||
saveCreatedEvent: mockSave,
|
saveCreatedEvent: mockSave,
|
||||||
getStoredEvents: vi.fn(() => []),
|
getStoredEvents: vi.fn(() => []),
|
||||||
getOrganizerToken: vi.fn(),
|
getOrganizerToken: vi.fn(),
|
||||||
|
saveRsvp: vi.fn(),
|
||||||
|
getRsvp: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -7,9 +7,24 @@ import { api } from '@/api/client'
|
|||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
api: {
|
api: {
|
||||||
GET: vi.fn(),
|
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) {
|
function createTestRouter(_token?: string) {
|
||||||
return createRouter({
|
return createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
@@ -26,6 +41,7 @@ async function mountWithToken(token = 'test-token') {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
return mount(EventDetailView, {
|
return mount(EventDetailView, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router] },
|
||||||
|
attachTo: document.body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +56,22 @@ const fullEvent = {
|
|||||||
expired: false,
|
expired: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockLoadedEvent(eventOverrides = {}) {
|
||||||
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
|
data: { ...fullEvent, ...eventOverrides },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
|
mockGetRsvp.mockReturnValue(undefined)
|
||||||
|
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('EventDetailView', () => {
|
describe('EventDetailView', () => {
|
||||||
// T014: Loading state
|
// Loading state
|
||||||
it('renders skeleton shimmer placeholders while loading', async () => {
|
it('renders skeleton shimmer placeholders while loading', async () => {
|
||||||
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
|
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
|
||||||
|
|
||||||
@@ -53,15 +79,12 @@ describe('EventDetailView', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||||
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
|
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 () => {
|
it('renders all event fields when loaded', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
mockLoadedEvent()
|
||||||
data: fullEvent,
|
|
||||||
error: undefined,
|
|
||||||
response: new Response(null, { status: 200 }),
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -71,37 +94,25 @@ describe('EventDetailView', () => {
|
|||||||
expect(wrapper.text()).toContain('Central Park, NYC')
|
expect(wrapper.text()).toContain('Central Park, NYC')
|
||||||
expect(wrapper.text()).toContain('12')
|
expect(wrapper.text()).toContain('12')
|
||||||
expect(wrapper.text()).toContain('Europe/Berlin')
|
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 () => {
|
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
mockLoadedEvent()
|
||||||
data: fullEvent,
|
|
||||||
error: undefined,
|
|
||||||
response: new Response(null, { status: 200 }),
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const dateField = wrapper.findAll('.detail__value')[0]!
|
const dateField = wrapper.findAll('.detail__value')[0]!
|
||||||
expect(dateField.text()).toContain('(Europe/Berlin)')
|
expect(dateField.text()).toContain('(Europe/Berlin)')
|
||||||
// The formatted date part is locale-dependent but should contain the year
|
|
||||||
expect(dateField.text()).toContain('2026')
|
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 () => {
|
it('does not render description and location when absent', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
|
||||||
data: {
|
|
||||||
...fullEvent,
|
|
||||||
description: undefined,
|
|
||||||
location: undefined,
|
|
||||||
attendeeCount: 0,
|
|
||||||
},
|
|
||||||
error: undefined,
|
|
||||||
response: new Response(null, { status: 200 }),
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -109,38 +120,33 @@ describe('EventDetailView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('Description')
|
expect(wrapper.text()).not.toContain('Description')
|
||||||
expect(wrapper.text()).not.toContain('Location')
|
expect(wrapper.text()).not.toContain('Location')
|
||||||
expect(wrapper.text()).toContain('0')
|
expect(wrapper.text()).toContain('0')
|
||||||
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
// T020 (US2): Expired state
|
// Expired state
|
||||||
it('renders "event has ended" banner when expired', async () => {
|
it('renders "event has ended" banner when expired', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
mockLoadedEvent({ expired: true })
|
||||||
data: { ...fullEvent, expired: true },
|
|
||||||
error: undefined,
|
|
||||||
response: new Response(null, { status: 200 }),
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('This event has ended.')
|
expect(wrapper.text()).toContain('This event has ended.')
|
||||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
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 () => {
|
it('does not render expired banner when event is active', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
mockLoadedEvent()
|
||||||
data: fullEvent,
|
|
||||||
error: undefined,
|
|
||||||
response: new Response(null, { status: 200 }),
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
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 () => {
|
it('renders "event not found" when API returns 404', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -152,11 +158,11 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Event not found.')
|
expect(wrapper.text()).toContain('Event not found.')
|
||||||
// No event data in DOM
|
|
||||||
expect(wrapper.find('.detail__title').exists()).toBe(false)
|
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 () => {
|
it('renders error state with retry button on server error', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -169,9 +175,10 @@ describe('EventDetailView', () => {
|
|||||||
|
|
||||||
expect(wrapper.text()).toContain('Something went wrong.')
|
expect(wrapper.text()).toContain('Something went wrong.')
|
||||||
expect(wrapper.find('button').text()).toBe('Retry')
|
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 () => {
|
it('retry button triggers a new fetch', async () => {
|
||||||
vi.mocked(api.GET)
|
vi.mocked(api.GET)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -194,5 +201,167 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user