224 lines
5.6 KiB
TypeScript
224 lines
5.6 KiB
TypeScript
|
|
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 - 1).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(Boolean);
|
||
|
|
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|