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

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

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

View File

@@ -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>

View 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>

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

View 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)
})
})