Files
hope-events/frontend/src/lib/eventSchema.ts
T
joshuaandClaude Opus 5.5 38262022ef Add event structured data, canonical URLs and referrer-based traffic sources
- Event pages emit schema.org Event JSON-LD (dates, venue, image,
  organizer, ZAR offers, sold-out status) for Google event results.
- Event pages, the events list and home page set a canonical URL so
  tagged and alias links don't split rankings.
- Funnel tracking falls back to the referring site (Facebook, Instagram,
  Google, ...) and fbclid/gclid when a link has no utm_source/src.
- Share and Save QR on the event page tag their links as share / qr.
- getServerSettings moves to lib/serverSettings.ts for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:02 +02:00

86 lines
3.4 KiB
TypeScript

// schema.org Event data for an event page, so Google can show the event with its date,
// venue and ticket link (event rich results / "events near me").
// Checked with https://search.google.com/test/rich-results
type SchemaOption = {
price: number;
stockLimit?: number;
availableCount?: number;
earlyBirdTiers?: { deadline: string; price: number }[];
variants?: { price: number | null; stockLimit?: number; availableCount?: number }[];
};
export type SchemaEvent = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
goLiveAt?: string;
picture?: string;
location?: string | null;
requiresRegistration?: boolean;
eventOptions?: SchemaOption[];
};
/** Today's price of each ticket (early-bird when one is open, else the variant/base price). */
function currentPrices(options: SchemaOption[], now: Date): number[] {
const prices: number[] = [];
for (const o of options) {
if (o.variants && o.variants.length > 0) {
for (const v of o.variants) prices.push(v.price ?? o.price);
continue;
}
const open = (o.earlyBirdTiers || []).filter((t) => now < new Date(t.deadline)).map((t) => t.price);
prices.push(open.length > 0 ? Math.min(...open) : o.price);
}
return prices.filter((p) => Number.isFinite(p));
}
export function eventJsonLd(
event: SchemaEvent,
{ pageUrl, imageUrl, organizerName, siteUrl }: { pageUrl: string; imageUrl: string | null; organizerName: string; siteUrl: string },
) {
const now = new Date();
const options = event.eventOptions || [];
const limited = options.filter((o) => (o.stockLimit ?? 0) > 0);
const soldOut = limited.length > 0 && limited.every((o) => o.availableCount !== undefined && o.availableCount <= 0);
// Contact-only events (requiresRegistration false) have no tickets to offer.
let offers: Record<string, unknown> | undefined;
if (event.requiresRegistration !== false) {
const prices = options.length > 0 ? currentPrices(options, now) : [0];
const low = Math.min(...prices);
const high = Math.max(...prices);
offers = {
"@type": low === high ? "Offer" : "AggregateOffer",
...(low === high ? { price: low.toFixed(2) } : { lowPrice: low.toFixed(2), highPrice: high.toFixed(2) }),
priceCurrency: "ZAR",
availability: soldOut ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
url: `${siteUrl}/register/${event.id}`,
...(event.goLiveAt ? { validFrom: new Date(event.goLiveAt).toISOString() } : {}),
};
}
return {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
...(event.description ? { description: event.description.slice(0, 5000) } : {}),
startDate: new Date(event.startDate).toISOString(),
endDate: new Date(event.endDate).toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
...(event.location ? { location: { "@type": "Place", name: event.location, address: event.location } } : {}),
...(imageUrl ? { image: [imageUrl] } : {}),
url: pageUrl,
organizer: { "@type": "Organization", name: organizerName, url: siteUrl },
...(offers ? { offers } : {}),
};
}
/** JSON for a <script type="application/ld+json">, safe against "</script>" in event text. */
export function jsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}