Allows guests to cancel their RSVP via a DELETE endpoint using their guestToken. Frontend shows cancel button in RsvpBar and clears local storage on success. Includes unit tests, integration tests, and E2E spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
525 lines
14 KiB
Vue
525 lines
14 KiB
Vue
<template>
|
||
<main class="detail">
|
||
<!-- Hero image with overlaid header -->
|
||
<div class="detail__hero">
|
||
<img
|
||
class="detail__hero-img"
|
||
src="@/assets/images/event-hero-placeholder.jpg"
|
||
alt=""
|
||
/>
|
||
<div class="detail__hero-overlay" />
|
||
<header class="detail__header">
|
||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||
<span class="detail__brand">fete</span>
|
||
</header>
|
||
</div>
|
||
|
||
<div class="detail__body">
|
||
<!-- Loading state -->
|
||
<div v-if="state === 'loading'" class="detail__content" 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__content">
|
||
<h1 class="detail__title">{{ event.title }}</h1>
|
||
|
||
<dl class="detail__meta">
|
||
<div class="detail__meta-item">
|
||
<dt class="detail__meta-icon glass" aria-label="Date and time">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||
</dt>
|
||
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||
</div>
|
||
|
||
<div v-if="event.location" class="detail__meta-item">
|
||
<dt class="detail__meta-icon glass" aria-label="Location">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||
</dt>
|
||
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||
</div>
|
||
|
||
<div class="detail__meta-item">
|
||
<dt class="detail__meta-icon glass" aria-label="Attendees">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||
</dt>
|
||
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||
|
||
<div v-if="event.description" class="detail__section">
|
||
<h2 class="detail__section-title">About</h2>
|
||
<p class="detail__description">{{ event.description }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Not found state -->
|
||
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
|
||
<p class="detail__message">Event not found.</p>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
|
||
<p class="detail__message">Something went wrong.</p>
|
||
<button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cancel error message -->
|
||
<div v-if="cancelError" class="detail__cancel-error" role="alert">
|
||
<p>{{ cancelError }}</p>
|
||
</div>
|
||
|
||
<!-- RSVP bar -->
|
||
<RsvpBar
|
||
v-if="state === 'loaded' && event && !isOrganizer"
|
||
:has-rsvp="!!rsvpName"
|
||
@open="sheetOpen = true"
|
||
@cancel="confirmCancelOpen = true"
|
||
/>
|
||
|
||
<!-- Cancel confirmation dialog -->
|
||
<ConfirmDialog
|
||
:open="confirmCancelOpen"
|
||
title="Cancel attendance?"
|
||
message="Your attendance will be permanently cancelled."
|
||
confirm-label="Cancel attendance"
|
||
cancel-label="Keep"
|
||
@confirm="handleCancelRsvp"
|
||
@cancel="confirmCancelOpen = false"
|
||
/>
|
||
|
||
<!-- 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 glass"
|
||
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>
|
||
<div class="rsvp-form__submit glow-border glow-border--animated">
|
||
<button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting">
|
||
{{ submitting ? 'Sending…' : "Count me in" }}
|
||
</button>
|
||
</div>
|
||
<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 AttendeeList from '@/components/AttendeeList.vue'
|
||
import BottomSheet from '@/components/BottomSheet.vue'
|
||
import ConfirmDialog from '@/components/ConfirmDialog.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, removeRsvp, 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 confirmCancelOpen = ref(false)
|
||
const cancelError = ref('')
|
||
const isOrganizer = ref(false)
|
||
const attendeeNames = ref<string[] | null>(null)
|
||
|
||
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.eventToken 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
|
||
const orgToken = getOrganizerToken(event.value.eventToken)
|
||
isOrganizer.value = !!orgToken
|
||
|
||
// Fetch attendee list for organizer
|
||
if (orgToken) {
|
||
fetchAttendees(event.value.eventToken, orgToken)
|
||
}
|
||
|
||
// 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.eventToken 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
|
||
}
|
||
}
|
||
|
||
async function handleCancelRsvp() {
|
||
confirmCancelOpen.value = false
|
||
cancelError.value = ''
|
||
|
||
const stored = getRsvp(route.params.eventToken as string)
|
||
if (!stored) return
|
||
|
||
try {
|
||
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
|
||
params: {
|
||
path: {
|
||
token: route.params.eventToken as string,
|
||
rsvpToken: stored.rsvpToken,
|
||
},
|
||
},
|
||
})
|
||
|
||
if (response.status === 204 || response.status === 404) {
|
||
removeRsvp(route.params.eventToken as string)
|
||
rsvpName.value = undefined
|
||
if (event.value) {
|
||
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
|
||
}
|
||
} else {
|
||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||
}
|
||
} catch {
|
||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||
}
|
||
}
|
||
|
||
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||
try {
|
||
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||
params: {
|
||
path: { token: eventToken },
|
||
query: { organizerToken },
|
||
},
|
||
})
|
||
|
||
if (!error) {
|
||
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||
}
|
||
} catch {
|
||
// Silently degrade — don't show attendee list
|
||
}
|
||
}
|
||
|
||
onMounted(fetchEvent)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* Break out of .app-container constraints */
|
||
width: 100dvw;
|
||
flex: 1;
|
||
position: relative;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
margin: calc(-1 * var(--content-padding)) 0;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* Hero image section */
|
||
.detail__hero {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 420px;
|
||
overflow: visible;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail__hero-img {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||
}
|
||
|
||
.detail__hero-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(
|
||
to bottom,
|
||
var(--color-glass-overlay) 0%,
|
||
transparent 50%
|
||
);
|
||
}
|
||
|
||
.detail__header {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-lg) var(--content-padding);
|
||
padding-top: env(safe-area-inset-top, var(--spacing-lg));
|
||
z-index: 1;
|
||
}
|
||
|
||
.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__body {
|
||
flex: 1;
|
||
padding: var(--spacing-lg) var(--content-padding);
|
||
padding-bottom: 6rem;
|
||
}
|
||
|
||
.detail__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-2xl);
|
||
max-width: var(--content-max-width);
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.detail__content--center {
|
||
align-items: center;
|
||
text-align: center;
|
||
padding-top: 4rem;
|
||
}
|
||
|
||
/* Title */
|
||
.detail__title {
|
||
font-size: 2rem;
|
||
font-weight: 800;
|
||
color: var(--color-text-on-gradient);
|
||
word-break: break-word;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
/* Meta rows: icon + text */
|
||
.detail__meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.detail__meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.detail__meta-icon {
|
||
flex-shrink: 0;
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 10px;
|
||
color: var(--color-text-on-gradient);
|
||
}
|
||
|
||
.detail__meta-text {
|
||
font-size: 0.9rem;
|
||
color: var(--color-text-on-gradient);
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* About section */
|
||
.detail__section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.detail__section-title {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: var(--color-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.detail__description {
|
||
font-size: 0.95rem;
|
||
color: var(--color-text-soft);
|
||
line-height: 1.6;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* Expired banner */
|
||
.detail__banner {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Error / not-found message */
|
||
.detail__message {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-on-gradient);
|
||
}
|
||
|
||
/* Skeleton – shimmer on gradient */
|
||
.skeleton {
|
||
background: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%);
|
||
background-size: 200% 100%;
|
||
}
|
||
|
||
.skeleton--title {
|
||
height: 2rem;
|
||
width: 70%;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.skeleton--line {
|
||
height: 1rem;
|
||
width: 85%;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.skeleton--short {
|
||
width: 45%;
|
||
}
|
||
|
||
/* RSVP submit button (glow border wrapper) */
|
||
.rsvp-form__submit {
|
||
width: 100%;
|
||
border-radius: var(--radius-button);
|
||
transition: transform 0.1s ease;
|
||
}
|
||
|
||
.rsvp-form__submit:hover {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.rsvp-form__submit:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.rsvp-form__submit-inner {
|
||
display: block;
|
||
width: 100%;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
border-radius: calc(var(--radius-button) - 2px);
|
||
font-family: inherit;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--color-text-on-gradient);
|
||
text-align: center;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.rsvp-form__submit-inner:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
</style>
|