2026-02-26 09:20:51 +01:00
"use client" ;
2026-04-12 10:11:23 +02:00
import { STATUS_MAP } from "@/config/constants" ;
import Image from "next/image" ;
import Link from "next/link" ;
2026-04-12 10:42:28 +02:00
import { useEffect , useMemo , useState , type CSSProperties } from "react" ;
2026-04-12 10:11:23 +02:00
type SortMethod = "updated" | "dist" | "alpha" ;
type Variant = "home" | "catalog" ;
type CourseStatus = {
status? : string ;
name? : string ;
} ;
type Facility = {
id : number ;
slug : string ;
name : string ;
2026-04-12 14:55:09 +02:00
description? : string | null ;
2026-04-12 10:11:23 +02:00
city? : string | null ;
county? : string | null ;
banetype? : string | null ;
image_url? : string | null ;
phone? : string | null ;
2026-04-12 11:19:35 +02:00
website_url? : string | null ;
golfbox_booking_url? : string | null ;
golfbox_tournament_url? : string | null ;
weather_url? : string | null ;
2026-04-12 10:11:23 +02:00
lat? : number | null ;
lng? : number | null ;
golfamore? : boolean | null ;
nsg_url? : string | null ;
vtg_pris? : number | null ;
vtg_lenke? : string | null ;
vtg_beskrivelse? : string | null ;
2026-04-12 10:42:28 +02:00
footnote? : string | null ;
footnote_updated_at? : string | null ;
2026-04-12 10:11:23 +02:00
status_updated_at? : string | null ;
amenities? : unknown ;
golfamore_data? : unknown ;
nsg_data? : unknown ;
vtg_datoer? : unknown ;
course_statuses? : unknown ;
} ;
type FacilitySearchProps = {
initialFacilities : Facility [ ] ;
variant? : Variant ;
eyebrow? : string ;
title? : string ;
intro? : string ;
} ;
type SpecialFlags = {
hasGolfamore : boolean ;
hasNSG : boolean ;
hasSimulator : boolean ;
hasDrivingRange : boolean ;
hasVtg : boolean ;
} ;
const AREA_GROUPS : Record < string , string [ ] > = {
"nord-norge" : [ "finnmark" , "troms" , "nordland" ] ,
"midt-norge" : [ "trondelag" , "nord-trondelag" , "sor-trondelag" ] ,
vestlandet : [ "more-og-romsdal" , "sogn-og-fjordane" , "hordaland" , "rogaland" , "vestland" ] ,
sorlandet : [ "vest-agder" , "aust-agder" , "agder" ] ,
ostlandet : [ "telemark" , "vestfold" , "ostfold" , "buskerud" , "hedmark" , "oppland" , "innlandet" , "viken" , "akershus" , "oslo" ] ,
"oslo-og-akershus" : [ "akershus" , "oslo" , "viken" ] ,
} ;
const HIERARCHICAL_AREA_OPTIONS = [
{ value : "" , label : "Hele Norge" } ,
{ value : "region:nord-norge" , label : "Nord-Norge" } ,
{ value : "county:finnmark" , label : "\u00A0\u00A0\u00A0Finnmark" } ,
{ value : "county:troms" , label : "\u00A0\u00A0\u00A0Troms" } ,
{ value : "county:nordland" , label : "\u00A0\u00A0\u00A0Nordland" } ,
{ value : "region:midt-norge" , label : "Midt-Norge" } ,
{ value : "county:nord-trondelag" , label : "\u00A0\u00A0\u00A0Nord-Trøndelag" } ,
{ value : "county:sor-trondelag" , label : "\u00A0\u00A0\u00A0Sør-Trøndelag" } ,
{ value : "county:trondelag" , label : "\u00A0\u00A0\u00A0Trøndelag" } ,
{ value : "region:vestlandet" , label : "Vestlandet" } ,
{ value : "county:more-og-romsdal" , label : "\u00A0\u00A0\u00A0Møre og Romsdal" } ,
{ value : "county:sogn-og-fjordane" , label : "\u00A0\u00A0\u00A0Sogn og Fjordane" } ,
{ value : "county:hordaland" , label : "\u00A0\u00A0\u00A0Hordaland" } ,
{ value : "county:rogaland" , label : "\u00A0\u00A0\u00A0Rogaland" } ,
{ value : "county:vestland" , label : "\u00A0\u00A0\u00A0Vestland" } ,
{ value : "region:sorlandet" , label : "Sørlandet" } ,
{ value : "county:vest-agder" , label : "\u00A0\u00A0\u00A0Vest-Agder" } ,
{ value : "county:aust-agder" , label : "\u00A0\u00A0\u00A0Aust-Agder" } ,
{ value : "county:agder" , label : "\u00A0\u00A0\u00A0Agder" } ,
{ value : "region:ostlandet" , label : "Østlandet" } ,
{ value : "county:telemark" , label : "\u00A0\u00A0\u00A0Telemark" } ,
{ value : "county:vestfold" , label : "\u00A0\u00A0\u00A0Vestfold" } ,
{ value : "county:ostfold" , label : "\u00A0\u00A0\u00A0Østfold" } ,
{ value : "county:buskerud" , label : "\u00A0\u00A0\u00A0Buskerud" } ,
{ value : "county:hedmark" , label : "\u00A0\u00A0\u00A0Hedmark" } ,
{ value : "county:oppland" , label : "\u00A0\u00A0\u00A0Oppland" } ,
{ value : "region:oslo-og-akershus" , label : "\u00A0\u00A0\u00A0Oslo og Akershus" } ,
{ value : "county:akershus" , label : "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" } ,
{ value : "county:oslo" , label : "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" } ,
{ value : "county:innlandet" , label : "\u00A0\u00A0\u00A0Innlandet" } ,
{ value : "county:viken" , label : "\u00A0\u00A0\u00A0Viken" } ,
] ;
const STATUS_ORDER = [
"aapen" ,
"aapen_med_vintergreener" ,
"stenger_snart" ,
"aapner_snart" ,
"ukjent" ,
"stengt" ,
"under_utvikling" ,
"nedlagt" ,
] ;
const STATUS_CLASSES : Record < string , string > = {
aapen : "bg-[#8BC34A] text-white" ,
aapen_med_vintergreener : "bg-[#D2A63A] text-[#112015]" ,
stenger_snart : "bg-[#FF5722] text-white" ,
aapner_snart : "bg-sky-600 text-white" ,
stengt : "bg-[#B6473D] text-white" ,
under_utvikling : "bg-slate-600 text-white" ,
nedlagt : "bg-[#112015] text-white" ,
ukjent : "bg-[#D9DED5] text-[#112015]" ,
} ;
const normalizeText = ( value : unknown ) = >
String ( value ? ? "" )
. toLowerCase ( )
. normalize ( "NFD" )
. replace ( /[\u0300-\u036f]/g , "" )
. replace ( /[^a-z0-9]+/g , " " )
. trim ( ) ;
const normalizeStatus = ( value : unknown ) = >
String ( value ? ? "" )
. toLowerCase ( )
. normalize ( "NFD" )
. replace ( /[\u0300-\u036f]/g , "" )
. replace ( /\s+/g , "_" )
. replace ( /[^a-z0-9_]+/g , "" )
. trim ( ) ;
const slugify = ( value : unknown ) = >
normalizeText ( value )
. replace ( /\s+/g , "-" )
. replace ( /^-+|-+$/g , "" ) ;
const parseJson = < T , > ( value : unknown , fallback : T ) : T = > {
if ( ! value ) return fallback ;
if ( typeof value === "object" ) return value as T ;
2026-02-26 09:20:51 +01:00
try {
2026-04-12 10:11:23 +02:00
return JSON . parse ( String ( value ) ) as T ;
} catch {
return fallback ;
}
} ;
const getDistance = ( lat1 : number , lon1 : number , lat2 : number , lon2 : number ) = > {
try {
const r = 6371 ;
const dLat = ( ( lat2 - lat1 ) * Math . PI ) / 180 ;
const dLon = ( ( lon2 - lon1 ) * Math . PI ) / 180 ;
const a =
Math . sin ( dLat / 2 ) * Math . sin ( dLat / 2 ) +
Math . cos ( ( lat1 * Math . PI ) / 180 ) *
Math . cos ( ( lat2 * Math . PI ) / 180 ) *
Math . sin ( dLon / 2 ) *
Math . sin ( dLon / 2 ) ;
return r * 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) ) ;
} catch {
return Number . POSITIVE_INFINITY ;
}
} ;
const hasTruthyAmenity = ( value : unknown ) = > {
const normalized = normalizeText ( value ) ;
return Boolean ( normalized ) && ! [ "nei" , "no" , "false" , "0" , "ingen" ] . includes ( normalized ) ;
} ;
const getFacilityRegions = ( county : string ) = > {
const countySlug = slugify ( county ) ;
return Object . entries ( AREA_GROUPS )
. filter ( ( [ , counties ] ) = > counties . includes ( countySlug ) )
. map ( ( [ region ] ) = > region ) ;
} ;
2026-02-26 09:20:51 +01:00
2026-04-12 10:11:23 +02:00
const getPrimaryStatus = ( statuses : Array < { status? : string } > ) = > {
for ( const candidate of STATUS_ORDER ) {
if ( statuses . some ( ( status ) = > normalizeStatus ( status . status ) === candidate ) ) {
return candidate ;
}
}
return "ukjent" ;
} ;
const formatUpdatedDate = ( value : string | null | undefined ) = > {
if ( ! value ) return "Ukjent" ;
const date = new Date ( value ) ;
if ( Number . isNaN ( date . getTime ( ) ) ) return "Ukjent" ;
return date . toLocaleDateString ( "nb-NO" , {
day : "2-digit" ,
month : "short" ,
year : "numeric" ,
} ) ;
} ;
const getStatusLabel = ( status : string ) = > STATUS_MAP [ status ] || "Ukjent" ;
2026-04-12 11:19:35 +02:00
const buildMapUrl = ( lat? : number | null , lng? : number | null ) = > {
if ( typeof lat !== "number" || typeof lng !== "number" ) return null ;
return ` https://www.google.com/maps/search/?api=1&query= ${ lat } , ${ lng } ` ;
} ;
2026-04-12 14:55:09 +02:00
const toPlainText = ( value : string | null | undefined ) = >
String ( value || "" )
. replace ( /<[^>]+>/g , " " )
. replace ( / /gi , " " )
. replace ( /\s+/g , " " )
. trim ( ) ;
2026-04-12 15:19:39 +02:00
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 sanitizeRichText = ( 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*p\s*>/gi , ( ) = > keep ( "<p>" ) )
. replace ( /<\s*\/\s*p\s*>/gi , ( ) = > keep ( "</p>" ) )
. 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*a\b([^>]*)>/gi , ( _ , attrs : string ) = > {
const hrefMatch = attrs . match ( /href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i ) ;
const href = sanitizeHref ( hrefMatch ? . [ 1 ] || hrefMatch ? . [ 2 ] || hrefMatch ? . [ 3 ] || "#" ) ;
return keep ( ` <a href=" ${ escapeHtml ( href ) } " target="_blank" rel="noreferrer"> ` ) ;
} )
. 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 10:11:23 +02:00
const getAreaLabel = ( value : string , countyOptions : Array < { slug : string ; label : string } > ) = > {
if ( ! value ) return "Hele Norge" ;
const builtIn = HIERARCHICAL_AREA_OPTIONS . find ( ( option ) = > option . value === value ) ;
if ( builtIn ) return builtIn . label . trim ( ) ;
if ( value . startsWith ( "county:" ) ) {
return countyOptions . find ( ( option ) = > option . slug === value . replace ( "county:" , "" ) ) ? . label || "Valgt fylke" ;
}
return "Valgt område" ;
} ;
const matchesHoleFilter = ( holeValue : string , filterValue : string ) = > {
const normalizedHole = normalizeText ( holeValue ) ;
if ( ! filterValue ) return true ;
if ( filterValue === "18-plus" ) return normalizedHole . includes ( "18" ) ;
if ( filterValue === "18" ) return normalizedHole === "18" ;
if ( filterValue === "9" ) return normalizedHole === "9" || normalizedHole === "9 9" ;
if ( filterValue === "6-12" ) return normalizedHole === "6" || normalizedHole === "12" ;
if ( filterValue === "under-utvikling" ) return normalizedHole . includes ( "utvikling" ) ;
return true ;
} ;
const matchesSpecialFilter = ( specialFilter : string , flags : SpecialFlags ) = > {
if ( ! specialFilter ) return true ;
if ( specialFilter === "golfamore" ) return flags . hasGolfamore ;
if ( specialFilter === "nsg" ) return flags . hasNSG ;
if ( specialFilter === "simulator" ) return flags . hasSimulator ;
if ( specialFilter === "drivingrange" ) return flags . hasDrivingRange ;
if ( specialFilter === "vtg" ) return flags . hasVtg ;
return true ;
} ;
const getSearchShellClasses = ( variant : Variant ) = >
variant === "home"
? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7"
: "surface-card rounded-[2rem] px-4 py-5 text-[#112015] sm:px-6 sm:py-7" ;
2026-04-12 10:42:28 +02:00
const noteClampStyle : CSSProperties = {
display : "-webkit-box" ,
WebkitBoxOrient : "vertical" ,
WebkitLineClamp : 3 ,
overflow : "hidden" ,
} ;
2026-04-12 11:19:35 +02:00
const actionIconClassName =
2026-04-12 14:55:09 +02:00
"flex h-7 w-7 items-center justify-center rounded-[0.8rem] border border-[#D5DDD1] bg-white text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]" ;
2026-04-12 11:19:35 +02:00
2026-04-12 10:11:23 +02:00
export default function FacilitySearch ( {
initialFacilities ,
variant = "catalog" ,
eyebrow = "Golfbaner" ,
title = "Alle golfbaner samlet på ett sted" ,
intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero." ,
} : FacilitySearchProps ) {
2026-02-26 09:20:51 +01:00
const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
2026-04-12 10:11:23 +02:00
const [ areaFilter , setAreaFilter ] = useState ( "" ) ;
const [ statusFilter , setStatusFilter ] = useState ( "" ) ;
const [ holeFilter , setHoleFilter ] = useState ( "" ) ;
const [ specialFilter , setSpecialFilter ] = useState ( "" ) ;
const [ sortMethod , setSortMethod ] = useState < SortMethod > ( "updated" ) ;
const [ userLocation , setUserLocation ] = useState < { lat : number ; lng : number } | null > ( null ) ;
2026-02-26 09:20:51 +01:00
useEffect ( ( ) = > {
2026-04-12 10:11:23 +02:00
if ( ! ( "geolocation" in navigator ) ) return ;
navigator . geolocation . getCurrentPosition (
( position ) = > {
setUserLocation ( { lat : position.coords.latitude , lng : position.coords.longitude } ) ;
setSortMethod ( ( current ) = > ( current === "updated" ? "dist" : current ) ) ;
} ,
( ) = > undefined ,
{
enableHighAccuracy : false ,
timeout : 8000 ,
maximumAge : 1000 * 60 * 30 ,
}
) ;
2026-02-26 09:20:51 +01:00
} , [ ] ) ;
2026-04-12 10:11:23 +02:00
const countyOptions = useMemo ( ( ) = > {
const unique = new Map < string , string > ( ) ;
for ( const facility of Array . isArray ( initialFacilities ) ? initialFacilities : [ ] ) {
const label = String ( facility ? . county || "" ) . trim ( ) ;
const slug = slugify ( label ) ;
if ( label && slug && ! unique . has ( slug ) ) unique . set ( slug , label ) ;
}
return Array . from ( unique . entries ( ) )
. map ( ( [ slug , label ] ) = > ( { slug , label } ) )
. sort ( ( a , b ) = > a . label . localeCompare ( b . label , "nb" ) ) ;
} , [ initialFacilities ] ) ;
const areaOptions = useMemo ( ( ) = > {
const seen = new Set < string > ( ) ;
const options = HIERARCHICAL_AREA_OPTIONS . filter ( ( option ) = > {
if ( seen . has ( option . value ) ) return false ;
seen . add ( option . value ) ;
return true ;
} ) ;
for ( const county of countyOptions ) {
const countyValue = ` county: ${ county . slug } ` ;
if ( ! seen . has ( countyValue ) ) {
options . push ( { value : countyValue , label : county.label } ) ;
seen . add ( countyValue ) ;
}
}
return options ;
} , [ countyOptions ] ) ;
const processedFacilities = useMemo ( ( ) = > {
2026-02-27 08:53:14 +01:00
if ( ! Array . isArray ( initialFacilities ) ) return [ ] ;
2026-02-26 09:20:51 +01:00
2026-04-12 10:11:23 +02:00
const stopWords = new Set ( [ "i" , "pa" , "for" , "med" , "av" , "og" , "de" , "den" , "det" , "bane" , "baner" ] ) ;
return initialFacilities
. map ( ( facility ) = > {
const amenities = parseJson < Record < string , unknown > > ( facility . amenities , { } ) ;
const golfamoreData = parseJson < Record < string , unknown > > ( facility . golfamore_data , { } ) ;
const nsgData = parseJson < Record < string , unknown > > ( facility . nsg_data , { } ) ;
const vtgDates = parseJson < unknown [ ] > ( facility . vtg_datoer , [ ] ) ;
const rawStatuses = parseJson < CourseStatus [ ] > ( facility . course_statuses , [ ] ) ;
const statuses =
Array . isArray ( rawStatuses ) && rawStatuses . length > 0
? rawStatuses
: [ { status : "ukjent" , name : "Hovedbane" } ] ;
const countySlug = slugify ( facility . county || "" ) ;
const regions = getFacilityRegions ( facility . county || "" ) ;
const holeValue = String ( amenities . antall_hull || "" ) . trim ( ) ;
const primaryStatus = getPrimaryStatus ( statuses ) ;
const normalizedStatuses = statuses . map ( ( status ) = > normalizeStatus ( status . status ) ) ;
const hasGolfamore = facility . golfamore === true || Object . keys ( golfamoreData ) . length > 0 ;
const hasNSG = Boolean ( facility . nsg_url ) || Object . keys ( nsgData ) . length > 0 ;
const hasSimulator = hasTruthyAmenity ( amenities . simulator ) ;
const hasDrivingRange = hasTruthyAmenity ( amenities . drivingrange ) ;
const hasVtg =
Boolean ( facility . vtg_pris ) ||
Boolean ( facility . vtg_lenke ) ||
Boolean ( facility . vtg_beskrivelse ) ||
( Array . isArray ( vtgDates ) && vtgDates . length > 0 ) ;
const updatedTsRaw = facility . status_updated_at ? new Date ( facility . status_updated_at ) . getTime ( ) : 0 ;
const lastUpdatedTs = Number . isFinite ( updatedTsRaw ) ? updatedTsRaw : 0 ;
const distance =
userLocation && facility . lat && facility . lng
? getDistance ( userLocation . lat , userLocation . lng , facility . lat , facility . lng )
: Number . POSITIVE_INFINITY ;
let searchBlob = [
facility . name ,
facility . city ,
facility . county ,
facility . banetype ,
holeValue ,
. . . statuses . map ( ( status ) = > status . name ) ,
. . . regions ,
]
. map ( ( value ) = > normalizeText ( value ) )
. join ( " " ) ;
if ( hasGolfamore ) searchBlob += " golfamore" ;
if ( hasNSG ) searchBlob += " nsg seniorgolf" ;
if ( hasSimulator ) searchBlob += " simulator" ;
if ( hasDrivingRange ) searchBlob += " drivingrange range" ;
if ( hasVtg ) searchBlob += " vtg veien til golf nybegynnerkurs" ;
if ( normalizedStatuses . includes ( "aapen" ) ) searchBlob += " apen apne" ;
if ( normalizedStatuses . includes ( "stengt" ) ) searchBlob += " stengt" ;
if ( normalizedStatuses . includes ( "aapen_med_vintergreener" ) ) searchBlob += " vinter vintergreener" ;
const words = normalizeText ( searchQuery )
. split ( /\s+/ )
. filter ( ( word ) = > word && ! stopWords . has ( word ) ) ;
const selectedArea = areaFilter . replace ( /^(region:|county:)/ , "" ) ;
const matchesSearch = words . every ( ( word ) = > searchBlob . includes ( word ) ) ;
const matchesArea =
! areaFilter ||
( areaFilter . startsWith ( "region:" ) &&
( regions . includes ( selectedArea ) ||
( AREA_GROUPS [ selectedArea ] ? AREA_GROUPS [ selectedArea ] . includes ( countySlug ) : false ) ) ) ||
( areaFilter . startsWith ( "county:" ) && countySlug === selectedArea ) ;
const matchesStatus = ! statusFilter || normalizedStatuses . includes ( statusFilter ) ;
const matchesHoles = matchesHoleFilter ( holeValue , holeFilter ) ;
const matchesSpecial = matchesSpecialFilter ( specialFilter , {
hasGolfamore ,
hasNSG ,
hasSimulator ,
hasDrivingRange ,
hasVtg ,
} ) ;
return {
. . . facility ,
holeValue ,
primaryStatus ,
hasGolfamore ,
hasNSG ,
hasVtg ,
distance ,
lastUpdatedTs ,
matchesSearch ,
matchesArea ,
matchesStatus ,
matchesHoles ,
matchesSpecial ,
} ;
} )
. filter (
( facility ) = >
facility . matchesSearch &&
facility . matchesArea &&
facility . matchesStatus &&
facility . matchesHoles &&
facility . matchesSpecial
)
. sort ( ( a , b ) = > {
if ( sortMethod === "dist" ) {
if ( a . distance !== b . distance ) return a . distance - b . distance ;
return a . name . localeCompare ( b . name , "nb" ) ;
}
if ( sortMethod === "updated" ) {
if ( a . lastUpdatedTs !== b . lastUpdatedTs ) return b . lastUpdatedTs - a . lastUpdatedTs ;
return a . name . localeCompare ( b . name , "nb" ) ;
}
return a . name . localeCompare ( b . name , "nb" ) ;
2026-03-06 13:39:11 +01:00
} ) ;
2026-04-12 10:11:23 +02:00
} , [ areaFilter , holeFilter , initialFacilities , searchQuery , sortMethod , specialFilter , statusFilter , userLocation ] ) ;
2026-03-06 13:39:11 +01:00
2026-04-12 10:11:23 +02:00
const filtersCount = [ areaFilter , statusFilter , holeFilter , specialFilter , searchQuery . trim ( ) ] . filter ( Boolean ) . length ;
const summaryText = ` ${ processedFacilities . length } baner • ${ getAreaLabel ( areaFilter , countyOptions ) } ${
filtersCount > 0 ? ` • ${ filtersCount } aktive filtre ` : ""
} ` ;
const labelClassName = variant === "home" ? "text-white/70" : "text-[#617063]" ;
2026-02-26 09:20:51 +01:00
return (
2026-04-12 10:11:23 +02:00
< section className = "mx-auto max-w-[1400px] px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10" >
{ variant === "catalog" && (
< div className = "mb-8 max-w-4xl" >
< p className = "mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]" > { eyebrow } < / p >
< h1 className = "section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl" > { title } < / h1 >
< p className = "mt-4 max-w-3xl text-base leading-8 text-[#617063]" > { intro } < / p >
< / div >
) }
< div className = { getSearchShellClasses ( variant ) } >
< div className = "mb-5" >
< h2 className = "section-title text-3xl sm:text-4xl" >
{ variant === "home" ? "Søk golfbaner" : "Filtrer oversikten" }
< / h2 >
< / div >
< div className = "grid gap-3 md:grid-cols-2 xl:grid-cols-4" >
< FieldSelect label = "Område" value = { areaFilter } onChange = { setAreaFilter } labelClassName = { labelClassName } >
{ areaOptions . map ( ( option ) = > (
< option key = { option . value || "all" } value = { option . value } >
{ option . label }
< / option >
) ) }
< / FieldSelect >
< FieldSelect label = "Banestatus" value = { statusFilter } onChange = { setStatusFilter } labelClassName = { labelClassName } >
< option value = "" > Alle statuser < / option >
< option value = "aapen" > Å pne baner < / option >
< option value = "aapen_med_vintergreener" > Vintergreener < / option >
< option value = "stenger_snart" > Stenger snart < / option >
< option value = "aapner_snart" > Å pner snart < / option >
< option value = "stengt" > Stengt < / option >
< option value = "ukjent" > Ukjent status < / option >
< / FieldSelect >
< FieldSelect label = "Antall hull" value = { holeFilter } onChange = { setHoleFilter } labelClassName = { labelClassName } >
< option value = "" > Alle anlegg < / option >
< option value = "18-plus" > 18 hull eller mer < / option >
< option value = "18" > Nøyaktig 18 hull < / option >
< option value = "9" > 9 hull < / option >
< option value = "6-12" > 6 eller 12 hull < / option >
< option value = "under-utvikling" > Under utvikling < / option >
< / FieldSelect >
< FieldSelect label = "Ekstra" value = { specialFilter } onChange = { setSpecialFilter } labelClassName = { labelClassName } >
< option value = "" > Ingen tillegg < / option >
< option value = "golfamore" > Golfamore < / option >
< option value = "nsg" > Seniorgolf / NSG < / option >
< option value = "simulator" > Simulator < / option >
< option value = "drivingrange" > Drivingrange < / option >
< option value = "vtg" > Tilbyr VTG < / option >
< / FieldSelect >
< / div >
< div className = "mt-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_auto]" >
< FieldInput
label = "Søk"
value = { searchQuery }
placeholder = 'For eksempel "åpne baner i Oslo"'
onChange = { setSearchQuery }
labelClassName = { labelClassName }
/ >
< FieldSelect
label = "Sortering"
value = { sortMethod }
onChange = { ( value ) = > setSortMethod ( value as SortMethod ) }
labelClassName = { labelClassName }
>
< option value = "updated" > Sist oppdatert < / option >
< option value = "alpha" > Alfabetisk < / option >
< option value = "dist" > Nærmest deg < / option >
< / FieldSelect >
< button
type = "button"
onClick = { ( ) = > {
setSearchQuery ( "" ) ;
setAreaFilter ( "" ) ;
setStatusFilter ( "" ) ;
setHoleFilter ( "" ) ;
setSpecialFilter ( "" ) ;
setSortMethod ( userLocation ? "dist" : "updated" ) ;
} }
className = { ` mt-[1.72rem] h-[52px] rounded-2xl px-5 text-[11px] font-extrabold uppercase tracking-[0.2em] transition ${
variant === "home" ? "bg-[#FF5722] text-white hover:bg-[#C94F2D]" : "bg-[#25312A] text-white hover:bg-[#39443B]"
} ` }
>
Nullstill
< / button >
< / div >
< div
className = { ` mt-4 rounded-[1.2rem] px-4 py-3 text-sm font-bold ${
variant === "home" ? "bg-white/10 text-white/90" : "bg-[#F3F6EE] text-[#617063]"
} ` }
>
< span > { summaryText } < / span >
< span className = { ` ml-2 ${ variant === "home" ? "text-white/65" : "text-[#839184]" } ` } >
{ sortMethod === "dist" && userLocation
? "Sortert etter avstand fra deg."
: sortMethod === "updated"
? "Sortert etter sist oppdatert."
: "Sortert alfabetisk." }
< / span >
< / div >
2026-02-26 09:20:51 +01:00
< / div >
2026-04-12 10:11:23 +02:00
{ processedFacilities . length === 0 ? (
< div className = "surface-card mt-6 rounded-[2rem] px-6 py-12 text-center" >
< p className = "text-lg font-extrabold text-[#112015]" > Ingen baner matcher filtrene akkurat nå . < / p >
< p className = "mt-2 text-sm text-[#617063]" > Prøv å nullstille filtrene eller velg et større område . < / p >
< / div >
) : (
< div className = "mt-6 grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3" >
{ processedFacilities . map ( ( facility ) = > (
2026-04-12 11:19:35 +02:00
< article
2026-04-12 10:11:23 +02:00
key = { facility . id }
2026-04-12 15:19:39 +02:00
className = "surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
2026-04-12 10:11:23 +02:00
>
2026-04-12 15:19:39 +02:00
< Link href = { ` /golfbaner/ ${ facility . slug } ` } className = "block shrink-0" >
2026-04-12 10:11:23 +02:00
< div className = "relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60" >
< Image
src = { facility . image_url || "/Toppbilde-standard.jpg" }
alt = { facility . name }
fill
sizes = "(max-width: 768px) 100vw, (max-width: 1536px) 50vw, 33vw"
className = "object-cover transition duration-700 group-hover:scale-105"
/ >
< div className = "absolute inset-0 bg-gradient-to-t from-[#25312A]/65 via-[#25312A]/10 to-transparent" / >
2026-04-12 14:55:09 +02:00
< div className = "absolute left-4 top-4 flex max-w-[calc(100%-7rem)] flex-col items-start gap-2" >
< div className = "flex flex-wrap gap-2" >
< span
className = { ` rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
STATUS_CLASSES [ facility . primaryStatus ] || STATUS_CLASSES . ukjent
} ` }
>
{ getStatusLabel ( facility . primaryStatus ) }
2026-04-12 10:11:23 +02:00
< / span >
2026-04-12 14:55:09 +02:00
{ facility . hasGolfamore && (
< span className = "rounded-full bg-[#FF5722] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white" >
Golfamore
< / span >
) }
{ facility . hasNSG && (
< span className = "rounded-full bg-[#2D6CB5] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white" >
NSG
< / span >
) }
< / div >
2026-02-27 09:35:30 +01:00
< / div >
2026-04-12 14:55:09 +02:00
{ facility . status_updated_at && (
< div className = "absolute right-4 top-4" >
< span className = "inline-flex rounded-full border border-white/15 bg-[#25312A]/72 px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.18em] text-white/90 backdrop-blur-sm shadow-sm" >
{ formatUpdatedDate ( facility . status_updated_at ) }
< / span >
< / div >
) }
2026-04-12 10:11:23 +02:00
< div className = "absolute bottom-4 left-4 right-4" >
< p className = "text-[10px] font-extrabold uppercase tracking-[0.22em] text-white/75" >
{ facility . city } • { facility . county }
< / p >
< h3 className = "mt-2 text-3xl text-white" > { facility . name } < / h3 >
< / div >
2026-02-27 08:53:14 +01:00
< / div >
2026-02-27 09:35:30 +01:00
2026-04-12 11:19:35 +02:00
< / Link >
2026-04-12 15:19:39 +02:00
< div className = "flex flex-1 flex-col p-5" >
< div className = "space-y-5" >
< div className = "flex items-start gap-3" >
< div className = "flex min-w-0 flex-wrap gap-2" >
< span className = "rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]" >
{ facility . holeValue || "--" } hull
< / span >
< span className = "rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]" >
{ facility . banetype || "Banetype ukjent" }
< / span >
< / div >
{ Number . isFinite ( facility . distance ) && (
< span className = "ml-auto shrink-0 self-center rounded-full bg-[#F7F8F3] px-3 py-1.5 text-right text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]" >
{ Math . round ( facility . distance ) } km unna
< / span >
) }
2026-04-12 14:55:09 +02:00
< / div >
2026-04-12 10:11:23 +02:00
2026-04-12 15:19:39 +02:00
{ facility . footnote && (
< div className = "rounded-[1.35rem] border border-[#FFD9CC] bg-[#FFF7F3] px-4 py-3" >
< p className = "text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]" >
{ formatUpdatedDate ( facility . footnote_updated_at || facility . status_updated_at ) }
< / p >
< p className = "mt-2 text-[15px] italic leading-7 text-[#7B3D2C]" style = { noteClampStyle } >
{ facility . footnote }
< / p >
< / div >
) }
2026-04-12 10:42:28 +02:00
2026-04-12 15:19:39 +02:00
{ String ( facility . description || "" ) . trim ( ) && (
< div
className = "text-[15px] leading-7 text-[#617063] [&_a]:font-bold [&_a]:text-[#FF5722] [&_a]:underline-offset-2 hover:[&_a]:text-[#C94F2D] [&_em]:italic [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-4 [&_p:last-child]:mb-0 [&_strong]:font-extrabold [&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-5"
dangerouslySetInnerHTML = { { __html : sanitizeRichText ( facility . description ) } }
/ >
) }
< / div >
2026-04-12 14:55:09 +02:00
2026-04-12 15:19:39 +02:00
< div className = "mt-auto pt-5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 text-sm font-bold text-[#112015]" >
2026-04-12 14:55:09 +02:00
< span className = "truncate text-[#617063]" > { facility . phone ? facility.phone : facility.city || "Se detaljer" } < / span >
< div className = "flex items-center justify-self-center gap-1" >
2026-04-12 11:19:35 +02:00
{ facility . website_url && (
< a href = { facility . website_url } target = "_blank" rel = "noreferrer" className = { actionIconClassName } aria-label = { ` Besøk nettsiden til ${ facility . name } ` } >
< ActionIcon type = "web" / >
< / a >
) }
{ facility . golfbox_booking_url && (
< a href = { facility . golfbox_booking_url } target = "_blank" rel = "noreferrer" className = { actionIconClassName } aria-label = { ` Book starttid hos ${ facility . name } ` } >
< ActionIcon type = "booking" / >
< / a >
) }
{ facility . golfbox_tournament_url && (
< a href = { facility . golfbox_tournament_url } target = "_blank" rel = "noreferrer" className = { actionIconClassName } aria-label = { ` Se turneringer hos ${ facility . name } ` } >
< ActionIcon type = "trophy" / >
< / a >
) }
{ buildMapUrl ( facility . lat , facility . lng ) && (
< a href = { buildMapUrl ( facility . lat , facility . lng ) || "#" } target = "_blank" rel = "noreferrer" className = { actionIconClassName } aria-label = { ` Åpne kart for ${ facility . name } ` } >
< ActionIcon type = "pin" / >
< / a >
) }
{ facility . weather_url && (
< a href = { facility . weather_url } target = "_blank" rel = "noreferrer" className = { actionIconClassName } aria-label = { ` Se været for ${ facility . name } ` } >
< ActionIcon type = "weather" / >
< / a >
) }
2026-04-12 14:55:09 +02:00
< / div >
< Link href = { ` /golfbaner/ ${ facility . slug } ` } className = "justify-self-end shrink-0 text-right text-[#FF5722] transition hover:text-[#C94F2D]" >
Se anlegg
2026-04-12 11:19:35 +02:00
< / Link >
2026-03-06 13:39:11 +01:00
< / div >
2026-02-26 09:20:51 +01:00
< / div >
2026-04-12 11:19:35 +02:00
< / article >
2026-04-12 10:11:23 +02:00
) ) }
< / div >
) }
< / section >
2026-02-26 09:20:51 +01:00
) ;
2026-04-12 10:11:23 +02:00
}
2026-04-12 11:19:35 +02:00
function ActionIcon ( { type } : { type : "web" | "booking" | "trophy" | "pin" | "weather" } ) {
return (
< svg
2026-04-12 14:55:09 +02:00
className = "h-3.5 w-3.5"
2026-04-12 11:19:35 +02:00
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
aria - hidden = "true"
>
{ type === "web" && (
< >
< circle cx = "12" cy = "12" r = "10" / >
< line x1 = "2" y1 = "12" x2 = "22" y2 = "12" / >
< path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" / >
< / >
) }
{ type === "booking" && (
< >
< path d = "M3 10h18" / >
< path d = "M7 15h.01" / >
< path d = "M11 15h.01" / >
< path d = "M15 15h.01" / >
< path d = "M7 19h.01" / >
< path d = "M11 19h.01" / >
< path d = "M15 19h.01" / >
< path d = "M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2z" / >
< path d = "M16 3v4" / >
< path d = "M8 3v4" / >
< / >
) }
{ type === "trophy" && (
< >
< path d = "M6 9H4.5a2.5 2.5 0 0 1 0-5H6" / >
< path d = "M18 9h1.5a2.5 2.5 0 0 0 0-5H18" / >
< path d = "M4 22h16" / >
< path d = "M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" / >
< path d = "M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" / >
< path d = "M18 2H6v7a6 6 0 0 0 12 0V2z" / >
< / >
) }
{ type === "pin" && (
< >
< path d = "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" / >
< circle cx = "12" cy = "10" r = "3" / >
< / >
) }
{ type === "weather" && (
< >
< path d = "M12 2v2" / >
< path d = "m4.93 4.93 1.41 1.41" / >
< path d = "M20 12h2" / >
< path d = "m19.07 4.93-1.41 1.41" / >
< path d = "M15.947 12.65a4 4 0 0 0-5.925-4.128" / >
< path d = "M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z" / >
< / >
) }
< / svg >
) ;
}
2026-04-12 10:11:23 +02:00
function FieldSelect ( {
label ,
value ,
onChange ,
labelClassName ,
children ,
} : {
label : string ;
value : string ;
onChange : ( value : string ) = > void ;
labelClassName : string ;
children : React.ReactNode ;
} ) {
return (
< label className = "block" >
< span className = { ` mb-2 block text-[10px] font-extrabold uppercase tracking-[0.22em] ${ labelClassName } ` } > { label } < / span >
< select value = { value } onChange = { ( event ) = > onChange ( event . target . value ) } className = "filter-field w-full px-4 py-3" >
{ children }
< / select >
< / label >
) ;
}
function FieldInput ( {
label ,
value ,
placeholder ,
onChange ,
labelClassName ,
} : {
label : string ;
value : string ;
placeholder : string ;
onChange : ( value : string ) = > void ;
labelClassName : string ;
} ) {
return (
< label className = "block" >
< span className = { ` mb-2 block text-[10px] font-extrabold uppercase tracking-[0.22em] ${ labelClassName } ` } > { label } < / span >
< input
value = { value }
placeholder = { placeholder }
onChange = { ( event ) = > onChange ( event . target . value ) }
className = "filter-field w-full px-4 py-3"
/ >
< / label >
) ;
}