Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbac248d8f | ||
|
|
b1af901d6a | ||
|
|
0bddb329e3 | ||
|
|
be4b6f02ea | ||
|
|
880e3206cc | ||
|
|
7b2bfc31e6 | ||
|
|
963c1824bf | ||
|
|
38262022ef |
@@ -7,6 +7,30 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- Event cards on the home page and events list show a diagonal "Early bird" ribbon in the top-right corner while the event has an early-bird tier running (deadline not passed and places left on that tier). It isn't shown when the event is sold out, registration is closed or not open yet, or the event is contact-only.
|
||||
|
||||
## [1.31.1] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- Settings → Organisation has a "Google Search Console verification" field. Paste the HTML tag from Search Console and the site outputs it on every page, so each deployment can verify its own domain without uploading a file.
|
||||
|
||||
## [1.31.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- The registration funnel's traffic source works without tagged links: visits from Facebook, Instagram, Google, X, LinkedIn and other sites are recognised from where the visitor came from (and Facebook's/Google's click ids). Links without a tag used to count as "direct". WhatsApp still only shows from tagged links (`?utm_source=whatsapp`), since it doesn't pass that on.
|
||||
- The event page's Share button shares the link tagged `?utm_source=share`, and Save QR encodes it tagged `?utm_source=qr`, so those visits show separately.
|
||||
- Event pages tell search engines about the event in Google's event format (schema.org `Event`): name, dates, venue, picture, organiser, ticket prices in Rand and whether it's sold out. Google can then show events with their date, venue and ticket link in search results and in its event listings. Contact-only events are listed without tickets.
|
||||
|
||||
### Changed
|
||||
|
||||
- Event pages, the events list and the home page tell search engines their one official address, so `?utm_source=…`, `?src=…` and alias links all count towards the same page in search rankings. Shared event links also carry the event's address.
|
||||
|
||||
## [1.30.1] - 2026-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ Four roles, each including everything the roles below it can do:
|
||||
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
|
||||
logged in).
|
||||
- **Events list** (`/events`) — event cards with picture, dates, location,
|
||||
price, and a Register / Sold Out / Contact us button.
|
||||
price, and a Register / Sold Out / Contact us button. A diagonal "Early bird"
|
||||
ribbon in the top-right corner while an early-bird tier is running and
|
||||
registration is open.
|
||||
- **Event detail** (`/events/[id]`) — description, ticket options with
|
||||
variant and early-bird pricing, low-stock and sold-out badges, downloadable
|
||||
attachments, embedded Google Map with "Directions" link, and an
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -190,6 +190,45 @@ const createEvent = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Map of eventId → when its early bird ends, for events with at least one early-bird
|
||||
// tier still running: deadline not passed and (no stock limit, or places left, counted
|
||||
// across tranches the same way as utils/pricing.js). The date is the latest deadline
|
||||
// among the running tiers.
|
||||
async function earlyBirdEndsByEvent(events) {
|
||||
const now = new Date();
|
||||
const running = [];
|
||||
for (const ev of events) {
|
||||
for (const opt of ev.eventOptions || []) {
|
||||
for (const tier of opt.earlyBirdTiers || []) {
|
||||
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
|
||||
const sold = new Map();
|
||||
if (limitedIds.length > 0) {
|
||||
const totals = await prisma.registrationOptionTranche.groupBy({
|
||||
by: ['appliedTierId'],
|
||||
where: {
|
||||
appliedTierId: { in: limitedIds },
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
|
||||
}
|
||||
|
||||
const ends = new Map();
|
||||
for (const { eventId, tier } of running) {
|
||||
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
|
||||
const deadline = new Date(tier.deadline);
|
||||
const current = ends.get(eventId);
|
||||
if (!current || deadline > current) ends.set(eventId, deadline);
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
// @desc Get all events
|
||||
// @route GET /api/events
|
||||
// @access Public
|
||||
@@ -220,9 +259,12 @@ const getEvents = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
|
||||
|
||||
// Compute isSoldOut per event: true when every option that has a stock limit
|
||||
// is fully sold out. Events with no limited options are never sold out.
|
||||
const withStock = await Promise.all(events.map(async ev => {
|
||||
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
||||
try {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
let isSoldOut = false;
|
||||
@@ -231,9 +273,9 @@ const getEvents = async (req, res) => {
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
||||
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
||||
}
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt };
|
||||
} catch {
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt };
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const PUBLIC_KEYS = [
|
||||
// the admin re-saves the Branding tab).
|
||||
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
|
||||
'setup_complete', 'app_base_url',
|
||||
// Google Search Console "HTML tag" code, output as a meta tag on every page
|
||||
'google_site_verification',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -32,6 +32,12 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
const inputCls =
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** The code from Search Console's verification meta tag; accepts the whole tag or just the code. */
|
||||
function googleVerificationCode(input: string): string {
|
||||
const m = input.match(/content\s*=\s*["']([^"']*)["']/i);
|
||||
return (m ? m[1] : input).trim();
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, hint, required, children,
|
||||
}: {
|
||||
@@ -104,6 +110,7 @@ function SiteSettingsPageInner() {
|
||||
const [orgPhone, setOrgPhone] = useState("");
|
||||
const [orgAddress, setOrgAddress] = useState("");
|
||||
const [appBaseUrl, setAppBaseUrl] = useState("");
|
||||
const [googleVerification, setGoogleVerification] = useState("");
|
||||
|
||||
// ── Branding ──
|
||||
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
||||
@@ -153,6 +160,7 @@ function SiteSettingsPageInner() {
|
||||
setOrgPhone(s.org_phone || "");
|
||||
setOrgAddress(s.org_address || "");
|
||||
setAppBaseUrl(s.app_base_url || "");
|
||||
setGoogleVerification(s.google_site_verification || "");
|
||||
// primary_color falls back to the legacy accent_color value (which used
|
||||
// to be "the one brand color" before this 3-color system existed).
|
||||
// accent_color is only trusted as the NEW tertiary color once
|
||||
@@ -209,6 +217,7 @@ function SiteSettingsPageInner() {
|
||||
org_phone: orgPhone.trim(),
|
||||
org_address: orgAddress.trim(),
|
||||
app_base_url: appBaseUrl.trim(),
|
||||
google_site_verification: googleVerificationCode(googleVerification),
|
||||
});
|
||||
|
||||
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
||||
@@ -432,6 +441,10 @@ function SiteSettingsPageInner() {
|
||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Google Search Console verification" hint="In Search Console, add this site as a URL-prefix property and choose the HTML tag method. Paste the tag (or just its content code) here and save, then click Verify in Search Console. Leave it set to stay verified.">
|
||||
<input className={inputCls} placeholder='<meta name="google-site-verification" content="..." />'
|
||||
value={googleVerification} onChange={e => setGoogleVerification(e.target.value)} />
|
||||
</Field>
|
||||
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,12 @@ import { API_BASE } from "@/lib/api";
|
||||
|
||||
type Props = { event: { id: string; title: string; description?: string | null } };
|
||||
|
||||
// The event's own address tagged with where the link was handed out, so visits from it
|
||||
// show under that source in the funnel report (the page's canonical drops the tag for SEO).
|
||||
function taggedEventUrl(source: string): string {
|
||||
return `${window.location.origin}${window.location.pathname}?utm_source=${source}`;
|
||||
}
|
||||
|
||||
export default function ClientActions({ event }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
@@ -15,7 +21,7 @@ export default function ClientActions({ event }: Props) {
|
||||
await navigator.share({
|
||||
title: event.title,
|
||||
text: event.description ?? undefined,
|
||||
url: window.location.href,
|
||||
url: taggedEventUrl("share"),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error sharing:", err);
|
||||
@@ -29,7 +35,7 @@ export default function ClientActions({ event }: Props) {
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const url = window.location.href;
|
||||
const url = taggedEventUrl("qr");
|
||||
const qrDataUrl = await QRCode.toDataURL(url);
|
||||
const link = document.createElement("a");
|
||||
link.href = qrDataUrl;
|
||||
|
||||
@@ -54,6 +54,9 @@ import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
import type { PublicBundle } from "@/types";
|
||||
import { appUrl, appName } from "@/lib/siteConfig";
|
||||
import { getServerSettings } from "@/lib/serverSettings";
|
||||
import { eventJsonLd, jsonLdString } from "@/lib/eventSchema";
|
||||
|
||||
// Tiered low-stock threshold: larger events use a smaller percentage.
|
||||
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
|
||||
@@ -147,10 +150,14 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
return {
|
||||
title: event.title,
|
||||
description,
|
||||
// One address per event for search engines, however the page was reached
|
||||
// (?utm_source=…, ?src=…, an alias link).
|
||||
alternates: { canonical: `/events/${event.id}` },
|
||||
openGraph: {
|
||||
title: event.title,
|
||||
description,
|
||||
type: "website",
|
||||
url: `/events/${event.id}`,
|
||||
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
@@ -167,8 +174,18 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
const event = await loadEvent(id);
|
||||
if (!event) notFound();
|
||||
|
||||
const settings = await getServerSettings();
|
||||
const siteUrl = appUrl.replace(/\/$/, "");
|
||||
const jsonLd = eventJsonLd(event, {
|
||||
pageUrl: `${siteUrl}/events/${event.id}`,
|
||||
imageUrl: event.picture ? resolveToApiOrigin(event.picture) : null,
|
||||
organizerName: settings.org_name || appName,
|
||||
siteUrl,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(jsonLd) }} />
|
||||
<Navbar />
|
||||
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
|
||||
<FunnelTracker eventId={event.id} stage="view" />
|
||||
|
||||
@@ -10,6 +10,7 @@ export const revalidate = 60;
|
||||
export const metadata: Metadata = {
|
||||
title: "All Events",
|
||||
description: "Browse and register for upcoming events.",
|
||||
alternates: { canonical: "/events" },
|
||||
};
|
||||
|
||||
type Event = {
|
||||
|
||||
@@ -2,40 +2,19 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
|
||||
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { ToastProvider } from "@/components/shared/ToastProvider";
|
||||
import { BannerBar } from "@/components/shared/BannerBar";
|
||||
import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { CookieConsent } from "@/components/shared/CookieConsent";
|
||||
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
|
||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { resolveToApiOrigin } from "@/lib/api";
|
||||
import { getServerSettings } from "@/lib/serverSettings";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
|
||||
/**
|
||||
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
|
||||
* below. Both call this with the identical URL + options so Next's per-request
|
||||
* fetch memoization dedupes them into a single network call. Degrades to `{}`
|
||||
* (static defaults everywhere downstream) if the backend is unreachable at
|
||||
* request time — intentional, not a bug: branding just isn't critical enough
|
||||
* to fail the whole page render over.
|
||||
*/
|
||||
async function getServerSettings(): Promise<SiteSettings> {
|
||||
// No API base configured: a relative fetch never settles during prerender and
|
||||
// would time out every page, so skip straight to the defaults.
|
||||
if (!API_BASE) return {};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getServerSettings();
|
||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||
@@ -63,6 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
description,
|
||||
...(logoUrl ? { images: [logoUrl] } : {}),
|
||||
},
|
||||
// Per-deployment Search Console ownership check (Settings → Organisation).
|
||||
...(settings.google_site_verification ? { verification: { google: settings.google_site_verification } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import type { Metadata } from "next";
|
||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ContactButton } from "@/components/events/ContactButton";
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
// Set by GET /api/events while an early-bird tier is still running.
|
||||
earlyBirdEndsAt?: string | null;
|
||||
};
|
||||
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
@@ -26,9 +28,30 @@ import { formatDateTimeRange } from "@/lib/date";
|
||||
export const EventCard = ({ event }: { event: Event }) => {
|
||||
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
|
||||
|
||||
const now = new Date();
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
|
||||
const registrationOpen = event.requiresRegistration !== false
|
||||
&& !(goLive && now < goLive)
|
||||
&& !(deadline && now >= deadline)
|
||||
&& now < new Date(event.endDate);
|
||||
const showEarlyBird = !!event.earlyBirdEndsAt && registrationOpen && !event.isSoldOut;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
|
||||
<div className="relative border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
|
||||
{showEarlyBird && (
|
||||
<div className="pointer-events-none absolute top-0 right-0 z-10 h-32 w-32 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute top-[26px] -right-[38px] w-[150px] rotate-45 bg-amber-400 py-1 text-center text-xs font-bold uppercase tracking-wide text-amber-950 shadow-md">
|
||||
Early bird
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<a href={`/events/${event.id}`} className="block">
|
||||
{showEarlyBird && (
|
||||
<span className="sr-only">
|
||||
Early bird pricing until {new Date(event.earlyBirdEndsAt!).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}.
|
||||
</span>
|
||||
)}
|
||||
{event.picture ? (
|
||||
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
|
||||
) : (
|
||||
|
||||
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source (short links and utm_source links) and device. It counts only visitors who allowed cookies.
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,9 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
<GuideItem icon={Building2} title="Site URL" tone="gray">
|
||||
The public URL of this site, used in email links like password resets and ticket delivery.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Building2} title="Google Search Console verification" tone="gray">
|
||||
Proves to Google that you own this site, so you can see search traffic and submit <code>/sitemap.xml</code> in Search Console. Add the site there as a URL-prefix property (e.g. <code>https://events.yourchurch.org/</code>), pick the <strong>HTML tag</strong> method, paste the tag here and save, then click Verify. Each site has its own code. Google finds your events without this; it only unlocks the Search Console reports.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const supervisorEventOptionsHelpContent: HelpContent = {
|
||||
<p>Pick an event, then edit each option's name, price, and "Main ticket" flag directly in the list — changes save when you click away from the field. Use the form below the list to add a new option.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Tag} title="Early-bird tiers" tone="amber">
|
||||
Expand "Manage early-bird tiers" on an option to add deadline-and-price rows that apply automatically before the cutoff.
|
||||
Expand "Manage early-bird tiers" on an option to add deadline-and-price rows that apply automatically before the cutoff. While a tier is running (before its deadline, with places left), the event's card on the home page and events list shows an "Early bird" ribbon in the top-right corner.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export type SiteSettings = {
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
setup_complete?: string;
|
||||
google_site_verification?: string;
|
||||
// Legal pages
|
||||
legal_operator_name?: string;
|
||||
legal_io_name?: string;
|
||||
|
||||
@@ -56,11 +56,45 @@ export function analyticsSessionId(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
|
||||
// Referring sites we name ourselves; any other site is recorded by its hostname.
|
||||
const REFERRER_SOURCES: [RegExp, string][] = [
|
||||
[/(^|\.)(facebook\.com|fb\.com|fb\.me)$/, "facebook"],
|
||||
[/(^|\.)instagram\.com$/, "instagram"],
|
||||
[/(^|\.)(whatsapp\.com|wa\.me)$/, "whatsapp"],
|
||||
[/(^|\.)google\.[a-z.]+$/, "google"],
|
||||
[/(^|\.)bing\.com$/, "bing"],
|
||||
[/(^|\.)duckduckgo\.com$/, "duckduckgo"],
|
||||
[/(^|\.)yahoo\.com$/, "yahoo"],
|
||||
[/(^|\.)(twitter\.com|x\.com|t\.co)$/, "x"],
|
||||
[/(^|\.)(linkedin\.com|lnkd\.in)$/, "linkedin"],
|
||||
[/(^|\.)(youtube\.com|youtu\.be)$/, "youtube"],
|
||||
[/(^|\.)tiktok\.com$/, "tiktok"],
|
||||
];
|
||||
|
||||
/** The referring site as a source label; undefined when there's none or it's this site. */
|
||||
function sourceFromReferrer(): string | undefined {
|
||||
try {
|
||||
if (!document.referrer) return undefined;
|
||||
const host = new URL(document.referrer).hostname.toLowerCase();
|
||||
if (!host || host === window.location.hostname.toLowerCase()) return undefined;
|
||||
for (const [re, label] of REFERRER_SOURCES) if (re.test(host)) return label;
|
||||
return host.replace(/^www\./, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the visitor came from: ?utm_source=… or ?src=<short-link alias> when the link
|
||||
* says so, else the referring site (Facebook, Google, …), else Facebook's/Google's click
|
||||
* ids. WhatsApp sends no referrer, so it's only known from a tagged link.
|
||||
*/
|
||||
function sourceFromUrl(): string | undefined {
|
||||
try {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
return p.get("utm_source") || p.get("src") || undefined;
|
||||
return p.get("utm_source") || p.get("src") || sourceFromReferrer()
|
||||
|| (p.has("fbclid") ? "facebook" : undefined)
|
||||
|| (p.has("gclid") ? "google" : undefined);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Server-side settings fetch, shared by the root layout (generateMetadata and
|
||||
* RootLayout) and server pages that need branding, e.g. the event page's organizer.
|
||||
* Every caller uses the identical URL + options, so Next's per-request fetch
|
||||
* memoization dedupes them into a single network call. Degrades to `{}`
|
||||
* (static defaults everywhere downstream) if the backend is unreachable at
|
||||
* request time — intentional, not a bug: branding just isn't critical enough
|
||||
* to fail the whole page render over.
|
||||
*/
|
||||
export async function getServerSettings(): Promise<SiteSettings> {
|
||||
// No API base configured: a relative fetch never settles during prerender and
|
||||
// would time out every page, so skip straight to the defaults.
|
||||
if (!API_BASE) return {};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.30.1",
|
||||
"version": "1.32.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user