- 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>
86 lines
3.4 KiB
TypeScript
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");
|
|
}
|