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>
318 lines
7.9 KiB
Vue
318 lines
7.9 KiB
Vue
<template>
|
|
<main class="detail">
|
|
<header class="detail__header">
|
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
|
<span class="detail__brand">fete</span>
|
|
</header>
|
|
|
|
<!-- Loading state -->
|
|
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
|
|
<div class="skeleton skeleton--title" />
|
|
<div class="skeleton skeleton--line" />
|
|
<div class="skeleton skeleton--line skeleton--short" />
|
|
<div class="skeleton skeleton--line" />
|
|
</div>
|
|
|
|
<!-- Loaded state -->
|
|
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
|
<h1 class="detail__title">{{ event.title }}</h1>
|
|
|
|
<dl class="detail__fields">
|
|
<div class="detail__field">
|
|
<dt class="detail__label">Date & Time</dt>
|
|
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
|
</div>
|
|
|
|
<div v-if="event.description" class="detail__field">
|
|
<dt class="detail__label">Description</dt>
|
|
<dd class="detail__value">{{ event.description }}</dd>
|
|
</div>
|
|
|
|
<div v-if="event.location" class="detail__field">
|
|
<dt class="detail__label">Location</dt>
|
|
<dd class="detail__value">{{ event.location }}</dd>
|
|
</div>
|
|
|
|
<div class="detail__field">
|
|
<dt class="detail__label">Attendees</dt>
|
|
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
|
This event has ended.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Not found state -->
|
|
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
|
|
<p class="detail__message">Event not found.</p>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
|
|
<p class="detail__message">Something went wrong.</p>
|
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
|
</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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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<State>('loading')
|
|
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(() => {
|
|
if (!event.value) return ''
|
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'long',
|
|
timeStyle: 'short',
|
|
}).format(new Date(event.value.dateTime))
|
|
return `${formatted} (${event.value.timezone})`
|
|
})
|
|
|
|
async function fetchEvent() {
|
|
state.value = 'loading'
|
|
event.value = null
|
|
|
|
try {
|
|
const { data, error, response } = await api.GET('/events/{token}', {
|
|
params: { path: { token: route.params.token as string } },
|
|
})
|
|
|
|
if (error) {
|
|
state.value = response.status === 404 ? 'not-found' : 'error'
|
|
return
|
|
}
|
|
|
|
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)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-2xl);
|
|
padding-top: var(--spacing-lg);
|
|
}
|
|
|
|
.detail__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.detail__back {
|
|
color: var(--color-text-on-gradient);
|
|
font-size: 1.5rem;
|
|
text-decoration: none;
|
|
line-height: 1;
|
|
}
|
|
|
|
.detail__brand {
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
color: var(--color-text-on-gradient);
|
|
}
|
|
|
|
.detail__card {
|
|
background: var(--color-card);
|
|
border-radius: var(--radius-card);
|
|
padding: var(--spacing-xl);
|
|
box-shadow: var(--shadow-card);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-lg);
|
|
}
|
|
|
|
.detail__card--center {
|
|
align-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.detail__title {
|
|
font-size: 1.4rem;
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
word-break: break-word;
|
|
}
|
|
|
|
.detail__fields {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.detail__field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
|
|
.detail__label {
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.detail__value {
|
|
font-size: 0.95rem;
|
|
color: var(--color-text);
|
|
word-break: break-word;
|
|
}
|
|
|
|
.detail__banner {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: var(--radius-card);
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.detail__banner--expired {
|
|
background: #fff3e0;
|
|
color: #e65100;
|
|
}
|
|
|
|
.detail__message {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
/* Skeleton sizes */
|
|
.skeleton--title {
|
|
height: 1.6rem;
|
|
width: 60%;
|
|
}
|
|
|
|
.skeleton--line {
|
|
height: 1rem;
|
|
width: 80%;
|
|
}
|
|
|
|
.skeleton--short {
|
|
width: 40%;
|
|
}
|
|
</style>
|