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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user