2026-04-12 22:07:51 +02:00
import type { Metadata } from "next" ;
2026-04-12 15:57:37 +02:00
import { notFound } from "next/navigation" ;
2026-04-28 07:42:49 +02:00
import { cache } from "react" ;
2026-04-19 12:33:05 +02:00
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer" ;
2026-04-12 15:57:37 +02:00
import {
2026-04-21 16:37:41 +02:00
buildPlaceAverageComparison ,
buildPlaceStats ,
buildPlaceStatsIntro ,
formatPlaceCount ,
formatPlaceCurrency ,
2026-04-12 15:57:37 +02:00
type FacilityRecord ,
enrichFacilities ,
filterFacilitiesByArea ,
getPlaceConfigFromSlug ,
2026-04-21 16:37:41 +02:00
getPlacePreposition ,
2026-04-12 15:57:37 +02:00
} from "@/app/facilityData" ;
import { API_URL } from "@/config/constants" ;
2026-04-12 22:07:51 +02:00
import {
createBreadcrumbJsonLd ,
createCollectionPageJsonLd ,
2026-04-18 09:00:16 +02:00
createItemListJsonLd ,
2026-04-12 22:07:51 +02:00
createPageMetadata ,
2026-04-28 07:42:49 +02:00
resolveSeoDescription ,
resolveSeoTitle ,
2026-04-12 22:07:51 +02:00
} from "@/app/seo" ;
2026-04-26 11:29:35 +02:00
import { fetchPublicFacilities } from "@/app/publicFacilities" ;
2026-04-12 15:57:37 +02:00
2026-04-24 09:17:14 +02:00
type PlacePageData = {
slug? : string ;
factbox_intro_html? : string | null ;
2026-04-28 07:42:49 +02:00
meta_title? : string | null ;
meta_description? : string | null ;
2026-04-24 09:17:14 +02:00
updated_at? : string | null ;
} ;
const escapeHtml = ( value : string ) = >
value
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ )
. replace ( /'/g , "'" ) ;
const sanitizeHref = ( value : string ) = > {
const href = value . trim ( ) ;
return /^(https?:|mailto:|tel:|\/|#)/i . test ( href ) ? href : "#" ;
} ;
const isInternalTeeoffHref = ( href : string ) = >
/^(\/|#|mailto:|tel:)/i . test ( href ) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i . test ( href ) ;
const sanitizePlaceRichText = ( value : string | null | undefined ) = > {
const source = String ( value || "" ) . replace ( /\r\n?/g , "\n" ) ;
if ( ! source . trim ( ) ) return "" ;
const placeholders = new Map < string , string > ( ) ;
let index = 0 ;
const keep = ( html : string ) = > {
const key = ` __HTML_TOKEN_ ${ index ++ } __ ` ;
placeholders . set ( key , html ) ;
return key ;
} ;
let safe = source
. replace ( /<\s*br\s*\/?\s*>/gi , ( ) = > keep ( "<br />" ) )
. replace ( /<\s*(strong|b)\s*>/gi , ( ) = > keep ( "<strong>" ) )
. replace ( /<\s*\/\s*(strong|b)\s*>/gi , ( ) = > keep ( "</strong>" ) )
. replace ( /<\s*(em|i)\s*>/gi , ( ) = > keep ( "<em>" ) )
. replace ( /<\s*\/\s*(em|i)\s*>/gi , ( ) = > keep ( "</em>" ) )
. replace ( /<\s*u\s*>/gi , ( ) = > keep ( "<u>" ) )
. replace ( /<\s*\/\s*u\s*>/gi , ( ) = > keep ( "</u>" ) )
. replace ( /<\s*(p|blockquote)\s*>/gi , ( _ , tag : string ) = > keep ( ` < ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*\/\s*(p|blockquote)\s*>/gi , ( _ , tag : string ) = > keep ( ` </ ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*(ul|ol)\s*>/gi , ( _ , tag : string ) = > keep ( ` < ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*\/\s*(ul|ol)\s*>/gi , ( _ , tag : string ) = > keep ( ` </ ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*li\s*>/gi , ( ) = > keep ( "<li>" ) )
. replace ( /<\s*\/\s*li\s*>/gi , ( ) = > keep ( "</li>" ) )
. replace ( /<\s*(h2|h3)\s*>/gi , ( _ , tag : string ) = > keep ( ` < ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*\/\s*(h2|h3)\s*>/gi , ( _ , tag : string ) = > keep ( ` </ ${ tag . toLowerCase ( ) } > ` ) )
. replace ( /<\s*a\b([^>]*)>/gi , ( _ , attrs : string ) = > {
const hrefMatch = attrs . match ( /href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i ) ;
const href = sanitizeHref ( hrefMatch ? . [ 1 ] || hrefMatch ? . [ 2 ] || hrefMatch ? . [ 3 ] || "#" ) ;
if ( isInternalTeeoffHref ( href ) ) {
return keep ( ` <a href=" ${ escapeHtml ( href ) } "> ` ) ;
}
return keep ( ` <a href=" ${ escapeHtml ( href ) } " target="_blank" rel="noreferrer noopener"> ` ) ;
} )
. replace ( /<\s*\/\s*a\s*>/gi , ( ) = > keep ( "</a>" ) ) ;
safe = escapeHtml ( safe ) . replace ( /\n/g , "<br />" ) ;
for ( const [ token , html ] of placeholders ) {
safe = safe . replaceAll ( token , html ) ;
}
return safe ;
} ;
2026-04-12 15:57:37 +02:00
export const dynamicParams = true ;
2026-04-22 06:48:15 +02:00
export const revalidate = 3600 ;
2026-04-12 15:57:37 +02:00
2026-04-28 07:42:49 +02:00
const fetchPlacePageData = cache ( async ( slug : string ) : Promise < PlacePageData | null > = > {
try {
const res = await fetch ( ` ${ API_URL } /place-pages/ ${ slug } ` , {
cache : "no-store" ,
} ) ;
if ( ! res . ok ) {
return null ;
}
return ( await res . json ( ) ) as PlacePageData ;
} catch {
return null ;
}
} ) ;
2026-04-12 22:07:51 +02:00
export async function generateMetadata ( {
params ,
} : {
params : Promise < { slug : string } > ;
} ) : Promise < Metadata > {
const { slug } = await params ;
const place = getPlaceConfigFromSlug ( slug ) ;
if ( ! place ) {
return createPageMetadata ( {
title : "Sted ikke funnet" ,
description : "Denne stedssiden finnes ikke på TeeOff." ,
path : ` /sted/ ${ slug } ` ,
} ) ;
}
2026-04-28 07:42:49 +02:00
const placePage = await fetchPlacePageData ( slug ) ;
const fallbackDescription = ` ${ place . intro } TeeOff samler golfbaner i ${ place . label } med oppdatert banestatus og baneprofiler. ` ;
2026-04-12 22:07:51 +02:00
return createPageMetadata ( {
2026-04-28 07:42:49 +02:00
title : resolveSeoTitle ( placePage ? . meta_title , place . title ) ,
description : resolveSeoDescription ( placePage ? . meta_description , fallbackDescription ) ,
2026-04-12 22:07:51 +02:00
path : ` /sted/ ${ slug } ` ,
} ) ;
}
2026-04-12 15:57:37 +02:00
export default async function PlacePage ( { params } : { params : Promise < { slug : string } > } ) {
const { slug } = await params ;
const place = getPlaceConfigFromSlug ( slug ) ;
if ( ! place ) {
notFound ( ) ;
}
2026-04-24 09:17:14 +02:00
let placePage : PlacePageData | null = null ;
2026-04-12 15:57:37 +02:00
2026-04-26 11:29:35 +02:00
const facilities = await fetchPublicFacilities < FacilityRecord > ( "place" , revalidate ) ;
2026-04-12 15:57:37 +02:00
2026-04-24 09:17:14 +02:00
try {
2026-04-28 07:42:49 +02:00
placePage = await fetchPlacePageData ( slug ) ;
if ( ! placePage ) {
throw new Error ( "API returnerte ingen sted-side." ) ;
2026-04-24 09:17:14 +02:00
}
} catch ( error ) {
console . error ( "Kritisk feil ved henting av sted-sideinnhold:" , error ) ;
placePage = null ;
}
2026-04-12 15:57:37 +02:00
const safeData = Array . isArray ( facilities ) ? facilities : [ ] ;
2026-04-21 16:37:41 +02:00
const enrichedFacilities = enrichFacilities ( safeData ) ;
const facilitiesInPlace = filterFacilitiesByArea ( enrichedFacilities , place . areaFilter ) ;
const placeStats = buildPlaceStats ( facilitiesInPlace ) ;
const nationalStats = buildPlaceStats ( enrichedFacilities ) ;
const placeStatsIntro = buildPlaceStatsIntro ( place . label , placeStats ) ;
const isNationalPlace = place . slug === "norge" ;
const placePreposition = isNationalPlace ? "i" : getPlacePreposition ( place . label ) ;
const greenfeeComparison = isNationalPlace
? null
: buildPlaceAverageComparison ( placeStats . avgPrimetimeGreenfee , nationalStats . avgPrimetimeGreenfee ) ;
const membershipComparison = isNationalPlace
? null
: buildPlaceAverageComparison ( placeStats . avgStandardMembership , nationalStats . avgStandardMembership ) ;
const holeDistributionParts = [
` ${ formatPlaceCount ( placeStats . par3HoleCount ) } par 3-hull ` ,
` ${ formatPlaceCount ( placeStats . par4HoleCount ) } par 4-hull ` ,
` ${ formatPlaceCount ( placeStats . par5HoleCount ) } par 5-hull ` ,
placeStats . par6HoleCount > 0 ? ` ${ formatPlaceCount ( placeStats . par6HoleCount ) } par 6-hull ` : null ,
] . filter ( ( part ) : part is string = > Boolean ( part ) ) ;
const holeDistributionText =
holeDistributionParts . length > 1
? ` ${ holeDistributionParts . slice ( 0 , - 1 ) . join ( ", " ) } og ${ holeDistributionParts . at ( - 1 ) } `
: holeDistributionParts [ 0 ] ? ? null ;
const shortestLongestText =
placeStats . shortestHoleMeters !== null && placeStats . longestHoleMeters !== null
? ` Det korteste golfhullet ${ placePreposition } ${ place . label } er ${ placeStats . shortestHoleMeters } meter, mens det lengste er ${ placeStats . longestHoleMeters } meter. `
: null ;
2026-04-12 22:07:51 +02:00
const collectionJsonLd = createCollectionPageJsonLd ( {
2026-04-28 07:42:49 +02:00
name : resolveSeoTitle ( placePage ? . meta_title , place . title ) ,
description : resolveSeoDescription (
placePage ? . meta_description ,
` ${ place . intro } TeeOff samler golfbaner i ${ place . label } med oppdatert banestatus og baneprofiler. ` ,
) ,
2026-04-12 22:07:51 +02:00
path : ` /sted/ ${ slug } ` ,
} ) ;
2026-04-18 09:00:16 +02:00
const itemListJsonLd = createItemListJsonLd ( {
2026-04-28 07:42:49 +02:00
name : resolveSeoTitle ( placePage ? . meta_title , place . title ) ,
2026-04-18 09:00:16 +02:00
path : ` /sted/ ${ slug } ` ,
items : facilitiesInPlace
. filter ( ( facility ) = > facility ? . slug && facility ? . name )
. map ( ( facility ) = > ( {
name : facility.name ,
path : ` /golfbaner/ ${ facility . slug } ` ,
description : facility.description ,
} ) ) ,
} ) ;
2026-04-12 22:07:51 +02:00
const breadcrumbJsonLd = createBreadcrumbJsonLd ( [
{ name : "Hjem" , path : "/" } ,
{ name : "Steder" , path : "/sted/norge" } ,
{ name : place.label , path : ` /sted/ ${ slug } ` } ,
] ) ;
2026-04-12 15:57:37 +02:00
return (
2026-04-12 22:07:51 +02:00
< >
< script
type = "application/ld+json"
dangerouslySetInnerHTML = { { __html : JSON.stringify ( collectionJsonLd ) } }
/ >
2026-04-18 09:00:16 +02:00
< script
type = "application/ld+json"
dangerouslySetInnerHTML = { { __html : JSON.stringify ( itemListJsonLd ) } }
/ >
2026-04-12 22:07:51 +02:00
< script
type = "application/ld+json"
dangerouslySetInnerHTML = { { __html : JSON.stringify ( breadcrumbJsonLd ) } }
/ >
< main className = "site-shell min-h-screen" >
< section className = "mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12" >
< div className = "max-w-4xl" >
< p className = "mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]" > Steder < / p >
< h1 className = "section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl" > { place . title } < / h1 >
< p className = "mt-4 max-w-3xl text-base leading-8 text-[#617063]" > { place . intro } < / p >
< div className = "mt-5 flex flex-wrap gap-3" >
< span className = "rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm" >
2026-04-21 16:37:41 +02:00
{ placeStats . facilityCount } golfanlegg
2026-04-12 22:07:51 +02:00
< / span >
< / div >
2026-04-12 15:57:37 +02:00
< / div >
2026-04-24 09:17:14 +02:00
{ placePage ? . factbox_intro_html ? (
< section className = "mt-8 overflow-hidden rounded-[2rem] border border-[#E7D8CE] bg-[#fff8f4] shadow-sm" >
< div className = "px-5 py-6 sm:px-6 lg:px-8 lg:py-8" >
< div
className = "max-w-4xl text-base leading-8 text-[#4e3d34] [&_a]:font-bold [&_a]:text-[#d53300] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#a82800] [&_blockquote]:border-l-4 [&_blockquote]:border-[#e4b9a8] [&_blockquote]:pl-4 [&_em]:italic [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-black [&_h2]:text-[#112015] [&_h2:first-child]:mt-0 [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-black [&_h3]:text-[#112015] [&_ol]:my-5 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-5 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_u]:underline [&_ul]:my-5 [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML = { { __html : sanitizePlaceRichText ( placePage . factbox_intro_html ) } }
/ >
< / div >
< / section >
) : null }
2026-04-21 16:37:41 +02:00
< section className = "mt-8 overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm" >
< div className = "px-5 py-6 sm:px-6 lg:px-8 lg:py-8" >
< div className = "max-w-5xl" >
< p className = "text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]" > Nøkkeltall < / p >
< h2 className = "mt-2 text-3xl text-[#112015] sm:text-4xl" >
{ isNationalPlace ? "Fakta om golfanleggene i Norge" : ` Fakta om golfanleggene ${ placePreposition } ${ place . label } ` }
< / h2 >
< p className = "mt-4 text-base leading-8 text-[#213127]" > { placeStatsIntro } < / p >
< / div >
< / div >
< div className = "grid gap-px border-t border-[#D7DED0] bg-[#D7DED0] lg:grid-cols-3" >
< div className = "bg-white/88 px-5 py-5 sm:px-6 lg:px-8" >
< p className = "text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]" > Greenfee < / p >
< p className = "mt-2 text-2xl font-black text-[#112015]" >
{ placeStats . avgPrimetimeGreenfee !== null ? formatPlaceCurrency ( placeStats . avgPrimetimeGreenfee ) : "Mangler grunnlag" }
< / p >
< p className = "mt-2 text-sm leading-6 text-[#4F6052]" >
{ placeStats . avgPrimetimeGreenfee !== null
? "Gjennomsnittlig primetime-pris i høysesong."
: "Ingen tilstrekkelige greenfee-data tilgjengelig." }
< / p >
{ greenfeeComparison ? < p className = "mt-2 text-sm font-bold text-[#112015]" > { greenfeeComparison } < / p > : null }
< / div >
< div className = "bg-white/88 px-5 py-5 sm:px-6 lg:px-8" >
< p className = "text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]" > Medlemskap < / p >
< p className = "mt-2 text-2xl font-black text-[#112015]" >
{ placeStats . avgStandardMembership !== null ? formatPlaceCurrency ( placeStats . avgStandardMembership ) : "Mangler grunnlag" }
< / p >
< p className = "mt-2 text-sm leading-6 text-[#4F6052]" >
{ placeStats . avgStandardMembership !== null
? "Gjennomsnittlig standard medlemskap med spillerett."
: "Ingen tilstrekkelige medlemskapsdata tilgjengelig." }
< / p >
{ membershipComparison ? < p className = "mt-2 text-sm font-bold text-[#112015]" > { membershipComparison } < / p > : null }
< / div >
< div className = "bg-white/88 px-5 py-5 sm:px-6 lg:px-8" >
< p className = "text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]" > Hullstatistikk < / p >
< p className = "mt-2 text-sm leading-7 text-[#112015]" >
{ formatPlaceCount ( placeStats . hole18Count , "neuter" ) } 18 - hullsanlegg
< br / >
{ formatPlaceCount ( placeStats . hole9Count , "neuter" ) } 9 - hullsanlegg
< br / >
{ formatPlaceCount ( placeStats . hole6Count , "neuter" ) } 6 - hullsanlegg
< br / >
{ formatPlaceCount ( placeStats . hole27PlusCount , "neuter" ) } anlegg med 27 hull eller mer
< / p >
{ holeDistributionText ? < p className = "mt-3 text-sm leading-6 text-[#4F6052]" > { holeDistributionText } . < / p > : null }
{ shortestLongestText ? < p className = "mt-2 text-sm leading-6 text-[#4F6052]" > { shortestLongestText } < / p > : null }
< / div >
< / div >
< / section >
2026-04-19 12:33:05 +02:00
< PlaceExplorer
2026-04-22 06:48:15 +02:00
facilities = { facilitiesInPlace }
2026-04-19 12:33:05 +02:00
placeLabel = { place . label }
placeAreaFilter = { place . areaFilter }
placeTitle = { place . title }
/ >
2026-04-12 22:07:51 +02:00
< / section >
< / main >
< / >
2026-04-12 15:57:37 +02:00
) ;
}