Nye-TeeOff/frontend/src/app/seo.ts

368 lines
9.6 KiB
TypeScript
Raw Normal View History

2026-04-12 22:07:51 +02:00
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(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
export function trimDescription(value: string | null | undefined, maxLength = 160) {
const plain = stripHtml(value);
if (plain.length <= maxLength) return plain;
2026-04-13 13:18:25 +02:00
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
2026-04-12 22:07:51 +02:00
}
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, []);
2026-04-13 13:18:25 +02:00
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(
(value): value is string => Boolean(value),
);
2026-04-12 22:07:51 +02:00
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,
},
};
}
2026-04-13 13:18:25 +02:00
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;
}
2026-04-12 22:07:51 +02:00
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;
}
}