{{ event.title }}
+ +-
+
- Date & Time +
- {{ formattedDateTime }} +
- Description +
- {{ event.description }} +
- Location +
- {{ event.location }} +
- Attendees +
- {{ event.attendeeCount }} +
diff --git a/frontend/e2e/event-create.spec.ts b/frontend/e2e/event-create.spec.ts
index 0db9f01..aac9ceb 100644
--- a/frontend/e2e/event-create.spec.ts
+++ b/frontend/e2e/event-create.spec.ts
@@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => {
await expect(page.getByText('Expiry date is required.')).toBeVisible()
})
- test('creates an event and redirects to stub page', async ({ page }) => {
+ test('creates an event and redirects to event detail page', async ({ page }) => {
await page.goto('/create')
await page.getByLabel(/title/i).fill('Summer BBQ')
@@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => {
await page.getByRole('button', { name: /create event/i }).click()
await expect(page).toHaveURL(/\/events\/.+/)
- await expect(page.getByText('Event created!')).toBeVisible()
})
test('stores event data in localStorage after creation', async ({ page }) => {
diff --git a/frontend/e2e/event-view.spec.ts b/frontend/e2e/event-view.spec.ts
new file mode 100644
index 0000000..e25c054
--- /dev/null
+++ b/frontend/e2e/event-view.spec.ts
@@ -0,0 +1,127 @@
+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('US-1: View event details', () => {
+ test('displays all event fields for a valid event', async ({ page, network }) => {
+ network.use(
+ http.get('*/api/events/:token', () => {
+ return HttpResponse.json(fullEvent)
+ }),
+ )
+
+ await page.goto(`/events/${fullEvent.eventToken}`)
+
+ await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
+ await expect(page.getByText('Bring your own drinks!')).toBeVisible()
+ await expect(page.getByText('Central Park, NYC')).toBeVisible()
+ await expect(page.getByText('12')).toBeVisible()
+ await expect(page.getByText('Europe/Berlin')).toBeVisible()
+ await expect(page.getByText('2026')).toBeVisible()
+ })
+
+ test('does not load external resources', async ({ page, network }) => {
+ const externalRequests: string[] = []
+ page.on('request', (req) => {
+ const url = new URL(req.url())
+ if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
+ externalRequests.push(req.url())
+ }
+ })
+
+ network.use(
+ http.get('*/api/events/:token', () => {
+ return HttpResponse.json(fullEvent)
+ }),
+ )
+
+ await page.goto(`/events/${fullEvent.eventToken}`)
+ await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
+
+ expect(externalRequests).toEqual([])
+ })
+})
+
+test.describe('US-2: View expired event', () => {
+ test('shows "event has ended" banner for 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()
+ })
+})
+
+test.describe('US-4: Event not found', () => {
+ test('shows "event not found" for unknown token', async ({ page, network }) => {
+ network.use(
+ http.get('*/api/events/:token', () => {
+ return HttpResponse.json(
+ { type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' },
+ { status: 404, headers: { 'Content-Type': 'application/problem+json' } },
+ )
+ }),
+ )
+
+ await page.goto('/events/00000000-0000-0000-0000-000000000000')
+
+ await expect(page.getByText('Event not found.')).toBeVisible()
+ // No event data visible
+ await expect(page.locator('.detail__title')).not.toBeVisible()
+ })
+})
+
+test.describe('Server error', () => {
+ test('shows error message and retry button on 500', async ({ page, network }) => {
+ network.use(
+ http.get('*/api/events/:token', () => {
+ return HttpResponse.json(
+ { type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' },
+ { status: 500, headers: { 'Content-Type': 'application/problem+json' } },
+ )
+ }),
+ )
+
+ await page.goto(`/events/${fullEvent.eventToken}`)
+
+ await expect(page.getByText('Something went wrong.')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
+ })
+
+ test('retry button re-fetches the event', async ({ page, network }) => {
+ let callCount = 0
+ network.use(
+ http.get('*/api/events/:token', () => {
+ callCount++
+ if (callCount === 1) {
+ return HttpResponse.json(
+ { type: 'about:blank', title: 'Error', status: 500 },
+ { status: 500, headers: { 'Content-Type': 'application/problem+json' } },
+ )
+ }
+ return HttpResponse.json(fullEvent)
+ }),
+ )
+
+ await page.goto(`/events/${fullEvent.eventToken}`)
+ await expect(page.getByText('Something went wrong.')).toBeVisible()
+
+ await page.getByRole('button', { name: 'Retry' }).click()
+
+ await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
+ })
+})
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 62440ad..2da8244 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -163,6 +163,19 @@ textarea.form-field {
padding-left: 0.25rem;
}
+/* Skeleton shimmer loading state */
+.skeleton {
+ background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-radius: var(--radius-card);
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
/* Utility */
.text-center {
text-align: center;
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 07bc62d..3d63d78 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -17,7 +17,7 @@ const router = createRouter({
{
path: '/events/:token',
name: 'event',
- component: () => import('../views/EventStubView.vue'),
+ component: () => import('../views/EventDetailView.vue'),
},
],
})
diff --git a/frontend/src/views/EventCreateView.vue b/frontend/src/views/EventCreateView.vue
index e48af62..aa8ea6c 100644
--- a/frontend/src/views/EventCreateView.vue
+++ b/frontend/src/views/EventCreateView.vue
@@ -184,6 +184,7 @@ async function handleSubmit() {
title: form.title.trim(),
description: form.description.trim() || undefined,
dateTime: dateTimeWithOffset,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
},
diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue
new file mode 100644
index 0000000..b946f98
--- /dev/null
+++ b/frontend/src/views/EventDetailView.vue
@@ -0,0 +1,214 @@
+
+ {{ event.title }}
+
+
+
+
+
+