Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite

Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

- "Add to calendar" .ics download on event pages and in confirmation emails
- sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages
- Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set
- Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download
- Admin audit trail for refunds, donations, manual registrations, event and
  settings changes, and staff-initiated cancellations
- Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug
  and the 1.4.2 donation-balance-inflation bug

Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet
needs a paid Apple Developer account the project doesn't have yet, and the
user preferred shipping both together later rather than Google alone now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 14:50:11 +02:00
co-authored by Claude Sonnet 5
parent 98ac26bf70
commit 54b89d4f4b
43 changed files with 8414 additions and 92 deletions
+10 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { Share2, QrCode } from "lucide-react";
import { Share2, QrCode, CalendarPlus } from "lucide-react";
import QRCode from "qrcode";
import { API_BASE } from "@/lib/api";
export default function ClientActions({ event }: { event: any }) {
return (
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
<QrCode className="w-4 h-4" />
Save QR
</button>
<a
href={`${API_BASE}/api/events/${event.id}/ics`}
download
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
<CalendarPlus className="w-4 h-4" />
Add to calendar
</a>
</div>
);
}
+41 -8
View File
@@ -1,3 +1,4 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
@@ -41,7 +42,7 @@ type Event = {
location?: string | null;
};
import { apiFetch, ApiError } from "@/lib/api";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
@@ -105,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
);
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let event: Event;
// Next dedupes an identical fetch (same URL + cache options) made during the same
// request, so calling this again from the page component below is free.
async function loadEvent(id: string): Promise<Event | null> {
try {
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
// The event endpoint 404s for missing, inactive, or not-yet-live events —
// render the standard not-found page instead of crashing.
if (e instanceof ApiError && e.status === 404) notFound();
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const event = await loadEvent(id);
if (!event) return {};
const description = event.description
? event.description.slice(0, 200)
: `${event.title}${formatDateTimeRange(event.startDate, event.endDate)}`;
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
return {
title: event.title,
description,
openGraph: {
title: event.title,
description,
type: "website",
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
...(imageUrl ? { images: [imageUrl] } : {}),
},
};
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = await loadEvent(id);
if (!event) notFound();
return (
<div className="min-h-screen flex flex-col">