Add EventDetailView with loading, expired, not-found, and error states

New view fetches event via openapi-fetch, formats date/time with
Intl.DateTimeFormat. Skeleton shimmer during loading (CSS-only).
Create form now sends auto-detected timezone.
Unit tests for all five view states, E2E tests with MSW mocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:34:00 +01:00
parent e5d0dd5f8f
commit 76b48d8b61
7 changed files with 555 additions and 3 deletions

View File

@@ -0,0 +1,214 @@
<template>
<main class="detail">
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</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 &amp; 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>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client'
import type { components } from '@/api/schema'
type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const state = ref<State>('loading')
const event = ref<GetEventResponse | 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.token as string } },
})
if (error) {
state.value = response.status === 404 ? 'not-found' : 'error'
return
}
event.value = data!
state.value = 'loaded'
} catch {
state.value = 'error'
}
}
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>