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(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(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 = { 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(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; } }