367 lines
9.6 KiB
TypeScript
Executable file
367 lines
9.6 KiB
TypeScript
Executable file
import type { Metadata } from "next";
|
|
import { FALLBACK_IMAGE } from "@/config/constants";
|
|
import type { FacilityRecord } from "@/app/facilityData";
|
|
|
|
type MetadataInput = {
|
|
title: string;
|
|
description: string;
|
|
path?: string;
|
|
image?: string | null;
|
|
type?: "website" | "article";
|
|
};
|
|
|
|
type BreadcrumbItem = {
|
|
name: string;
|
|
path: string;
|
|
};
|
|
|
|
type CollectionPageInput = {
|
|
name: string;
|
|
description: string;
|
|
path: string;
|
|
};
|
|
|
|
type SocialLink = {
|
|
url?: string | null;
|
|
};
|
|
|
|
type FacilitySeoRecord = FacilityRecord & {
|
|
address?: string | null;
|
|
email?: string | null;
|
|
social_links?: unknown;
|
|
};
|
|
|
|
export const SITE_NAME = "TeeOff";
|
|
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
|
|
export const DEFAULT_DESCRIPTION =
|
|
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfanlegg samlet på ett sted.";
|
|
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
|
|
export const ORGANIZATION_ID = `${SITE_URL}#organization`;
|
|
export const WEBSITE_ID = `${SITE_URL}#website`;
|
|
|
|
const SAME_AS_LINKS = [
|
|
"https://www.facebook.com/TeeOff.norge/",
|
|
"https://twitter.com/TeeOffno",
|
|
"https://www.instagram.com/teeoffno/",
|
|
];
|
|
|
|
export function buildAbsoluteUrl(path = "/") {
|
|
if (!path) return SITE_URL;
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
|
}
|
|
|
|
export function resolveImageUrl(image?: string | null) {
|
|
if (!image) return DEFAULT_OG_IMAGE;
|
|
return buildAbsoluteUrl(image);
|
|
}
|
|
|
|
export function stripHtml(value: string | null | undefined) {
|
|
return String(value || "")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/ /gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export function trimDescription(value: string | null | undefined, maxLength = 160) {
|
|
const plain = stripHtml(value);
|
|
if (plain.length <= maxLength) return plain;
|
|
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
|
|
}
|
|
|
|
export function createPageMetadata({
|
|
title,
|
|
description,
|
|
path = "/",
|
|
image,
|
|
type = "website",
|
|
}: MetadataInput): Metadata {
|
|
const ogImage = resolveImageUrl(image);
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
alternates: {
|
|
canonical: path,
|
|
},
|
|
openGraph: {
|
|
type,
|
|
locale: "nb_NO",
|
|
siteName: SITE_NAME,
|
|
title,
|
|
description,
|
|
url: buildAbsoluteUrl(path),
|
|
images: [
|
|
{
|
|
url: ogImage,
|
|
width: 1600,
|
|
height: 900,
|
|
alt: title,
|
|
},
|
|
],
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
site: "@TeeOffno",
|
|
creator: "@TeeOffno",
|
|
title,
|
|
description,
|
|
images: [ogImage],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createOrganizationJsonLd() {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Organization",
|
|
"@id": ORGANIZATION_ID,
|
|
name: SITE_NAME,
|
|
url: SITE_URL,
|
|
logo: {
|
|
"@type": "ImageObject",
|
|
url: buildAbsoluteUrl("/icons/cropped-siteicon-1.png"),
|
|
},
|
|
sameAs: SAME_AS_LINKS,
|
|
};
|
|
}
|
|
|
|
export function createWebsiteJsonLd() {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebSite",
|
|
"@id": WEBSITE_ID,
|
|
url: SITE_URL,
|
|
name: SITE_NAME,
|
|
inLanguage: "nb-NO",
|
|
publisher: {
|
|
"@id": ORGANIZATION_ID,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createBreadcrumbJsonLd(items: BreadcrumbItem[]) {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: items.map((item, index) => ({
|
|
"@type": "ListItem",
|
|
position: index + 1,
|
|
name: item.name,
|
|
item: buildAbsoluteUrl(item.path),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function createCollectionPageJsonLd({ name, description, path }: CollectionPageInput) {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "CollectionPage",
|
|
name,
|
|
description,
|
|
url: buildAbsoluteUrl(path),
|
|
inLanguage: "nb-NO",
|
|
isPartOf: {
|
|
"@id": WEBSITE_ID,
|
|
},
|
|
about: {
|
|
"@id": ORGANIZATION_ID,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
|
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
|
|
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(
|
|
(value): value is string => Boolean(value),
|
|
);
|
|
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "GolfCourse",
|
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
|
|
name: facility.name,
|
|
description:
|
|
trimDescription(facility.description) ||
|
|
`${facility.name} er et golfanlegg på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
|
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
|
image: resolveImageUrl(facility.image_url),
|
|
telephone: facility.phone || undefined,
|
|
email: facility.email || undefined,
|
|
address:
|
|
facility.address || facility.city || facility.county
|
|
? {
|
|
"@type": "PostalAddress",
|
|
streetAddress: facility.address || undefined,
|
|
addressLocality: facility.city || undefined,
|
|
addressRegion: facility.county || undefined,
|
|
addressCountry: "NO",
|
|
}
|
|
: undefined,
|
|
geo:
|
|
typeof facility.lat === "number" && typeof facility.lng === "number"
|
|
? {
|
|
"@type": "GeoCoordinates",
|
|
latitude: facility.lat,
|
|
longitude: facility.lng,
|
|
}
|
|
: undefined,
|
|
sameAs: sameAs.length > 0 ? sameAs : undefined,
|
|
isPartOf: {
|
|
"@id": WEBSITE_ID,
|
|
},
|
|
};
|
|
}
|
|
|
|
type VtgDateRecord = {
|
|
dato?: string;
|
|
status?: string;
|
|
};
|
|
|
|
export function createVtgCourseJsonLd(facility: FacilitySeoRecord) {
|
|
const dates = parseJson<VtgDateRecord[]>(facility.vtg_datoer, [])
|
|
.map((entry) => ({
|
|
raw: stripHtml(entry?.dato || ""),
|
|
status: stripHtml(entry?.status || ""),
|
|
comparableDate: parseComparableDate(entry?.dato || ""),
|
|
}))
|
|
.filter((entry) => entry.raw && entry.comparableDate);
|
|
|
|
if (
|
|
dates.length === 0 &&
|
|
!facility.vtg_pris &&
|
|
!facility.vtg_beskrivelse &&
|
|
!facility.vtg_lenke
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Course",
|
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#vtg-course`),
|
|
name: `Veien til Golf hos ${facility.name}`,
|
|
description:
|
|
trimDescription(facility.vtg_beskrivelse) ||
|
|
`Nybegynnerkurs i golf hos ${facility.name}, samlet og strukturert av TeeOff.`,
|
|
provider: {
|
|
"@type": "SportsActivityLocation",
|
|
name: facility.name,
|
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
|
},
|
|
hasCourseInstance: dates.map((entry, index) => ({
|
|
"@type": "CourseInstance",
|
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#vtg-instance-${index + 1}`),
|
|
courseMode: "onsite",
|
|
startDate: entry.comparableDate?.toISOString(),
|
|
location: {
|
|
"@type": "GolfCourse",
|
|
name: facility.name,
|
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
|
},
|
|
offers:
|
|
typeof facility.vtg_pris === "number"
|
|
? {
|
|
"@type": "Offer",
|
|
price: facility.vtg_pris,
|
|
priceCurrency: "NOK",
|
|
url: facility.vtg_lenke || buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
|
availability:
|
|
entry.status.toLowerCase().includes("full")
|
|
? "https://schema.org/SoldOut"
|
|
: "https://schema.org/InStock",
|
|
}
|
|
: undefined,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function parseComparableDate(raw: string) {
|
|
const trimmed = String(raw || "").trim();
|
|
if (!trimmed) return null;
|
|
|
|
const isoCandidate = new Date(trimmed);
|
|
if (!Number.isNaN(isoCandidate.getTime())) {
|
|
isoCandidate.setHours(0, 0, 0, 0);
|
|
return isoCandidate;
|
|
}
|
|
|
|
const numericDateMatch = trimmed.match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
|
|
if (numericDateMatch) {
|
|
const day = Number(numericDateMatch[1]);
|
|
const month = Number(numericDateMatch[2]) - 1;
|
|
const yearValue = Number(numericDateMatch[3]);
|
|
const year = yearValue < 100 ? 2000 + yearValue : yearValue;
|
|
const parsed = new Date(year, month, day);
|
|
parsed.setHours(0, 0, 0, 0);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
const normalized = trimmed
|
|
.toLowerCase()
|
|
.replace(/[.,]/g, " ")
|
|
.replace(/\s+/g, " ");
|
|
|
|
const monthMap: Record<string, number> = {
|
|
januar: 0,
|
|
jan: 0,
|
|
februar: 1,
|
|
feb: 1,
|
|
mars: 2,
|
|
mar: 2,
|
|
april: 3,
|
|
apr: 3,
|
|
mai: 4,
|
|
juni: 5,
|
|
jun: 5,
|
|
juli: 6,
|
|
jul: 6,
|
|
august: 7,
|
|
aug: 7,
|
|
september: 8,
|
|
sep: 8,
|
|
sept: 8,
|
|
oktober: 9,
|
|
okt: 9,
|
|
november: 10,
|
|
nov: 10,
|
|
desember: 11,
|
|
des: 11,
|
|
};
|
|
|
|
const monthToken = Object.keys(monthMap).find((monthName) => normalized.includes(monthName));
|
|
if (!monthToken) return null;
|
|
|
|
const dayMatch = normalized.match(/(\d{1,2})/);
|
|
if (!dayMatch) return null;
|
|
|
|
const explicitYearMatch = normalized.match(/\b(20\d{2})\b/);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
let year = explicitYearMatch ? Number(explicitYearMatch[1]) : today.getFullYear();
|
|
const day = Number(dayMatch[1]);
|
|
const monthIndex = monthMap[monthToken];
|
|
|
|
let parsed = new Date(year, monthIndex, day);
|
|
parsed.setHours(0, 0, 0, 0);
|
|
|
|
if (!explicitYearMatch && parsed.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) {
|
|
year += 1;
|
|
parsed = new Date(year, monthIndex, day);
|
|
parsed.setHours(0, 0, 0, 0);
|
|
}
|
|
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
function parseJson<T>(value: unknown, fallback: T): T {
|
|
if (!value) return fallback;
|
|
if (typeof value === "object") return value as T;
|
|
try {
|
|
return JSON.parse(String(value)) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|