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-24 09:37:01 +02:00
import { useRouter } from "next/navigation" ;
2026-04-12 10:42:28 +02:00
import { useEffect , useMemo , useState , type CSSProperties } from "react" ;
2026-04-20 09:39:30 +02:00
import { getPublicCourseDisplayName , type EnrichedFacility } from "@/app/facilityData" ;
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-16 09:58:08 +02:00
architect? : string | null ;
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 ;
2026-04-19 13:35:48 +02:00
golfamore_url? : string | null ;
2026-04-12 10:11:23 +02:00
nsg_url? : string | null ;
2026-04-26 11:29:35 +02:00
has_golfpakker? : boolean | null ;
2026-04-12 10:11:23 +02:00
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 ;
2026-04-19 10:24:33 +02:00
weather_forecast? : unknown ;
} ;
type WeatherForecastDay = {
day_offset? : number ;
dry_all_day? : boolean ;
dry_daylight? : boolean ;
precip_mm? : number ;
precip_probability_max? : number ;
daylight_precip_mm? : number ;
daylight_precip_probability_max? : number ;
confidence? : string ;
2026-04-12 10:11:23 +02:00
} ;
type FacilitySearchProps = {
initialFacilities : Facility [ ] ;
variant? : Variant ;
eyebrow? : string ;
title? : string ;
intro? : string ;
2026-04-12 15:57:37 +02:00
fixedAreaFilter? : string ;
hideTitleBlock? : boolean ;
2026-04-19 12:33:05 +02:00
onFilteredFacilitiesChange ? : ( facilities : EnrichedFacility [ ] ) = > void ;
filterHeading? : string ;
2026-04-12 10:11:23 +02:00
} ;
type SpecialFlags = {
hasGolfamore : boolean ;
hasNSG : boolean ;
2026-04-26 11:29:35 +02:00
hasGolfPackages : boolean ;
2026-04-12 10:11:23 +02:00
hasSimulator : boolean ;
} ;
2026-04-16 09:58:08 +02:00
const HIDDEN_COUNTY_SLUGS = new Set ( [ "innlandet" , "viken" ] ) ;
2026-04-12 10:11:23 +02:00
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" ] ,
} ;
2026-04-15 09:19:16 +02:00
const COUNTY_FILTER_ALIASES : Record < string , string [ ] > = {
trondelag : [ "trondelag" , "nord-trondelag" , "sor-trondelag" ] ,
} ;
2026-04-12 10:11:23 +02:00
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" } ,
2026-04-16 09:58:08 +02:00
{ value : "county:trondelag" , label : "Trøndelag" } ,
2026-04-12 10:11:23 +02:00
{ value : "county:nord-trondelag" , label : "\u00A0\u00A0\u00A0Nord-Trøndelag" } ,
{ value : "county:sor-trondelag" , label : "\u00A0\u00A0\u00A0Sør-Trø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 : "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" } ,
] ;
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]" ,
} ;
2026-04-17 22:46:57 +02:00
const MONTH_LABELS = [ "jan." , "feb." , "mars" , "apr." , "mai" , "juni" , "juli" , "aug." , "sep." , "okt." , "nov." , "des." ] ;
2026-04-12 10:11:23 +02:00
const normalizeText = ( value : unknown ) = >
String ( value ? ? "" )
2026-04-15 09:19:16 +02:00
. replace ( /[æøå]/gi , ( char ) = > {
const normalized = char . toLowerCase ( ) ;
if ( normalized === "æ" ) return "ae" ;
if ( normalized === "ø" ) return "o" ;
if ( normalized === "å" ) return "a" ;
return normalized ;
} )
2026-04-12 10:11:23 +02:00
. 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" ;
2026-04-17 22:46:57 +02:00
const trimmed = String ( value ) . trim ( ) ;
const isoDateMatch = trimmed . match ( /^(\d{4})-(\d{2})-(\d{2})/ ) ;
if ( isoDateMatch ) {
const [ , year , month , day ] = isoDateMatch ;
const monthIndex = Number ( month ) - 1 ;
if ( monthIndex >= 0 && monthIndex < MONTH_LABELS . length ) {
return ` ${ day } . ${ MONTH_LABELS [ monthIndex ] } ${ year } ` ;
}
}
const date = new Date ( trimmed ) ;
2026-04-12 10:11:23 +02:00
if ( Number . isNaN ( date . getTime ( ) ) ) return "Ukjent" ;
2026-04-17 22:46:57 +02:00
const day = String ( date . getUTCDate ( ) ) . padStart ( 2 , "0" ) ;
const monthLabel = MONTH_LABELS [ date . getUTCMonth ( ) ] ;
const year = date . getUTCFullYear ( ) ;
return ` ${ day } . ${ monthLabel } ${ year } ` ;
2026-04-12 10:11:23 +02:00
} ;
const getStatusLabel = ( status : string ) = > STATUS_MAP [ status ] || "Ukjent" ;
2026-04-20 09:39:30 +02:00
type StatusBadge = {
label : string ;
status : string ;
} ;
const buildFacilityStatusBadges = ( statuses : CourseStatus [ ] ) : StatusBadge [ ] = > {
const normalizedStatuses = ( Array . isArray ( statuses ) ? statuses : [ ] )
. map ( ( status ) = > ( {
name : String ( status ? . name || "" ) . trim ( ) ,
status : normalizeStatus ( status ? . status ) || "ukjent" ,
} ) )
. filter ( ( status ) = > status . status ) ;
if ( normalizedStatuses . length === 0 ) {
return [ { label : getStatusLabel ( "ukjent" ) , status : "ukjent" } ] ;
}
const uniqueStatuses = [ . . . new Set ( normalizedStatuses . map ( ( status ) = > status . status ) ) ] ;
if ( normalizedStatuses . length === 1 || uniqueStatuses . length === 1 ) {
const status = uniqueStatuses [ 0 ] || "ukjent" ;
return [ { label : getStatusLabel ( status ) , status } ] ;
}
return normalizedStatuses . map ( ( course , index ) = > {
const courseName = getPublicCourseDisplayName ( course . name , index , normalizedStatuses . length ) ;
return {
label : courseName ? ` ${ courseName } : ${ getStatusLabel ( course . status ) } ` : getStatusLabel ( course . status ) ,
status : course.status ,
} ;
} ) ;
} ;
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-18 09:00:16 +02:00
const formatPhoneHref = ( phone : string ) = > {
const normalized = phone . replace ( /[^\d+]/g , "" ) ;
return normalized . startsWith ( "00" ) ? ` + ${ normalized . slice ( 2 ) } ` : normalized ;
} ;
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 : "#" ;
} ;
2026-04-17 09:25:32 +02:00
const getSiteOrigin = ( ) = > {
if ( typeof window !== "undefined" && window . location ? . origin ) {
return window . location . origin ;
}
return process . env . NEXT_PUBLIC_SITE_URL || "https://teeoff.no" ;
} ;
2026-04-13 13:18:25 +02:00
const isInternalTeeoffHref = ( href : string ) = > {
if ( ! href || href . startsWith ( "/" ) || href . startsWith ( "#" ) || href . startsWith ( "mailto:" ) || href . startsWith ( "tel:" ) ) {
return true ;
}
try {
2026-04-17 09:25:32 +02:00
const url = new URL ( href , getSiteOrigin ( ) ) ;
2026-04-13 13:18:25 +02:00
return url . hostname === "teeoff.no" || url . hostname . endsWith ( ".teeoff.no" ) ;
} catch {
return false ;
}
} ;
2026-04-12 15:19:39 +02:00
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 ] || "#" ) ;
2026-04-13 13:18:25 +02:00
if ( isInternalTeeoffHref ( href ) ) {
return keep ( ` <a href=" ${ escapeHtml ( href ) } "> ` ) ;
}
return keep ( ` <a href=" ${ escapeHtml ( href ) } " target="_blank" rel="noreferrer noopener"> ` ) ;
2026-04-12 15:19:39 +02:00
} )
. 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 ;
2026-04-26 11:29:35 +02:00
if ( specialFilter === "golfpakke" ) return flags . hasGolfPackages ;
2026-04-12 10:11:23 +02:00
if ( specialFilter === "simulator" ) return flags . hasSimulator ;
return true ;
} ;
2026-04-24 09:17:14 +02:00
const OSLO_TIME_ZONE = "Europe/Oslo" ;
const WEATHER_DAY_BASE_OPTIONS = [
2026-04-19 10:24:33 +02:00
{ value : "" , label : "Alle dager" } ,
{ value : "0" , label : "I dag" } ,
{ value : "1" , label : "I morgen" } ,
{ value : "2" , label : "Om 2 dager" } ,
{ value : "3" , label : "Om 3 dager" } ,
{ value : "4" , label : "Om 4 dager" } ,
{ value : "5" , label : "Om 5 dager" } ,
{ value : "6" , label : "Om 6 dager" } ,
{ value : "7" , label : "Om en uke" } ,
] ;
2026-04-24 09:17:14 +02:00
const osloDateFormatter = new Intl . DateTimeFormat ( "nb-NO" , {
timeZone : OSLO_TIME_ZONE ,
year : "numeric" ,
month : "2-digit" ,
day : "2-digit" ,
} ) ;
const osloWeekdayFormatter = new Intl . DateTimeFormat ( "nb-NO" , {
timeZone : OSLO_TIME_ZONE ,
weekday : "long" ,
} ) ;
const getOsloDateParts = ( date : Date ) = > {
const parts = osloDateFormatter . formatToParts ( date ) ;
const year = Number ( parts . find ( ( part ) = > part . type === "year" ) ? . value ? ? "0" ) ;
const month = Number ( parts . find ( ( part ) = > part . type === "month" ) ? . value ? ? "1" ) ;
const day = Number ( parts . find ( ( part ) = > part . type === "day" ) ? . value ? ? "1" ) ;
return { year , month , day } ;
} ;
const getOsloDateAtNoonUtc = ( date : Date ) = > {
const { year , month , day } = getOsloDateParts ( date ) ;
return new Date ( Date . UTC ( year , month - 1 , day , 12 ) ) ;
} ;
const getWeatherDayOptions = ( date : Date = new Date ( ) ) = > {
const osloToday = getOsloDateAtNoonUtc ( date ) ;
return WEATHER_DAY_BASE_OPTIONS . map ( ( option ) = > {
if ( ! option . value ) return option ;
const dayOffset = Number . parseInt ( option . value , 10 ) ;
const targetDate = new Date ( osloToday ) ;
targetDate . setUTCDate ( targetDate . getUTCDate ( ) + dayOffset ) ;
return {
. . . option ,
label : ` ${ option . label } ( ${ osloWeekdayFormatter . format ( targetDate ) } ) ` ,
} ;
} ) ;
} ;
const getOsloDateKey = ( date : Date = new Date ( ) ) = > {
const { year , month , day } = getOsloDateParts ( date ) ;
return ` ${ year } - ${ String ( month ) . padStart ( 2 , "0" ) } - ${ String ( day ) . padStart ( 2 , "0" ) } ` ;
} ;
const getMsUntilNextOsloDateChange = ( date : Date = new Date ( ) ) = > {
const minuteMs = 60 _000 ;
const currentKey = getOsloDateKey ( date ) ;
let low = date . getTime ( ) ;
let high = low + minuteMs ;
while ( getOsloDateKey ( new Date ( high ) ) === currentKey ) {
high += 30 * minuteMs ;
}
while ( high - low > minuteMs ) {
const mid = Math . floor ( ( low + high ) / 2 ) ;
if ( getOsloDateKey ( new Date ( mid ) ) === currentKey ) {
low = mid ;
} else {
high = mid ;
}
}
return Math . max ( minuteMs , high - date . getTime ( ) + minuteMs ) ;
} ;
2026-04-12 10:11:23 +02:00
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." ,
2026-04-12 15:57:37 +02:00
fixedAreaFilter = "" ,
hideTitleBlock = false ,
2026-04-19 12:33:05 +02:00
onFilteredFacilitiesChange ,
filterHeading = "Søk golfbaner" ,
2026-04-12 10:11:23 +02:00
} : FacilitySearchProps ) {
2026-04-24 09:37:01 +02:00
const router = useRouter ( ) ;
2026-02-26 09:20:51 +01:00
const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
2026-04-12 15:57:37 +02:00
const [ areaFilter , setAreaFilter ] = useState ( fixedAreaFilter ) ;
2026-04-12 10:11:23 +02:00
const [ statusFilter , setStatusFilter ] = useState ( "" ) ;
const [ holeFilter , setHoleFilter ] = useState ( "" ) ;
const [ specialFilter , setSpecialFilter ] = useState ( "" ) ;
2026-04-19 10:24:33 +02:00
const [ weatherDayFilter , setWeatherDayFilter ] = useState ( "" ) ;
2026-04-24 09:17:14 +02:00
const [ weatherDayOptions , setWeatherDayOptions ] = useState ( ( ) = > getWeatherDayOptions ( ) ) ;
2026-04-16 09:58:08 +02:00
const [ architectFilter , setArchitectFilter ] = useState ( "" ) ;
2026-04-12 10:11:23 +02:00
const [ sortMethod , setSortMethod ] = useState < SortMethod > ( "updated" ) ;
const [ userLocation , setUserLocation ] = useState < { lat : number ; lng : number } | null > ( null ) ;
2026-04-19 09:49:47 +02:00
const [ isMobileSearchOpen , setIsMobileSearchOpen ] = useState ( false ) ;
2026-02-26 09:20:51 +01:00
2026-04-12 15:57:37 +02:00
useEffect ( ( ) = > {
setAreaFilter ( fixedAreaFilter ) ;
} , [ fixedAreaFilter ] ) ;
2026-04-24 09:17:14 +02:00
useEffect ( ( ) = > {
let timeoutId : number | undefined ;
let cancelled = false ;
const scheduleRefresh = ( ) = > {
timeoutId = window . setTimeout ( ( ) = > {
if ( cancelled ) return ;
setWeatherDayOptions ( getWeatherDayOptions ( ) ) ;
scheduleRefresh ( ) ;
} , getMsUntilNextOsloDateChange ( ) ) ;
} ;
scheduleRefresh ( ) ;
return ( ) = > {
cancelled = true ;
if ( timeoutId !== undefined ) {
window . clearTimeout ( timeoutId ) ;
}
} ;
} , [ ] ) ;
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 ) ;
2026-04-16 09:58:08 +02:00
if ( label && slug && ! HIDDEN_COUNTY_SLUGS . has ( slug ) && ! unique . has ( slug ) ) unique . set ( slug , label ) ;
2026-04-12 10:11:23 +02:00
}
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 ] ) ;
2026-04-16 09:58:08 +02:00
const architectOptions = useMemo ( ( ) = > {
const unique = new Map < string , string > ( ) ;
for ( const facility of Array . isArray ( initialFacilities ) ? initialFacilities : [ ] ) {
const label = String ( facility ? . architect || "" ) . trim ( ) ;
const key = normalizeText ( label ) ;
if ( label && key && ! unique . has ( key ) ) unique . set ( key , label ) ;
}
return Array . from ( unique . entries ( ) )
. map ( ( [ value , label ] ) = > ( { value , label } ) )
. sort ( ( a , b ) = > a . label . localeCompare ( b . label , "nb" ) ) ;
} , [ initialFacilities ] ) ;
const facilityOptions = useMemo ( ( ) = > {
return ( Array . isArray ( initialFacilities ) ? initialFacilities : [ ] )
. filter ( ( facility ) = > facility ? . slug && facility ? . name )
. map ( ( facility ) = > ( {
value : facility.slug ,
label : facility.name ,
} ) )
. sort ( ( a , b ) = > a . label . localeCompare ( b . label , "nb" ) ) ;
} , [ initialFacilities ] ) ;
2026-04-12 10:11:23 +02:00
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 rawStatuses = parseJson < CourseStatus [ ] > ( facility . course_statuses , [ ] ) ;
2026-04-19 10:24:33 +02:00
const weatherForecast = parseJson < WeatherForecastDay [ ] > ( facility . weather_forecast , [ ] ) ;
2026-04-12 10:11:23 +02:00
const statuses =
Array . isArray ( rawStatuses ) && rawStatuses . length > 0
? rawStatuses
2026-04-20 09:39:30 +02:00
: [ { status : "ukjent" , name : "" } ] ;
2026-04-12 10:11:23 +02:00
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 ) ) ;
2026-04-19 13:35:48 +02:00
const hasGolfamore =
facility . golfamore === true ||
Boolean ( facility . golfamore_url ) ||
Object . keys ( golfamoreData ) . length > 0 ;
2026-04-12 10:11:23 +02:00
const hasNSG = Boolean ( facility . nsg_url ) || Object . keys ( nsgData ) . length > 0 ;
2026-04-26 11:29:35 +02:00
const hasGolfPackages = facility . has_golfpakker === true ;
2026-04-12 10:11:23 +02:00
const hasSimulator = hasTruthyAmenity ( amenities . simulator ) ;
2026-04-19 12:33:05 +02:00
const hasDrivingRange = hasTruthyAmenity ( amenities . drivingrange ) ;
const vtgDates = parseJson < unknown [ ] > ( facility . vtg_datoer , [ ] ) ;
const hasVtg =
Boolean ( facility . vtg_pris ) ||
Boolean ( facility . vtg_lenke ) ||
Boolean ( facility . vtg_beskrivelse ) ||
( Array . isArray ( vtgDates ) && vtgDates . length > 0 ) ;
2026-04-16 09:58:08 +02:00
const architectKey = normalizeText ( facility . architect || "" ) ;
2026-04-12 10:11:23 +02:00
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 ,
2026-04-16 09:58:08 +02:00
facility . architect ,
2026-04-12 10:11:23 +02:00
holeValue ,
. . . statuses . map ( ( status ) = > status . name ) ,
. . . regions ,
]
. map ( ( value ) = > normalizeText ( value ) )
. join ( " " ) ;
if ( hasGolfamore ) searchBlob += " golfamore" ;
if ( hasNSG ) searchBlob += " nsg seniorgolf" ;
2026-04-26 11:29:35 +02:00
if ( hasGolfPackages ) searchBlob += " golfpakke golfpakker" ;
2026-04-12 10:11:23 +02:00
if ( hasSimulator ) searchBlob += " simulator" ;
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:)/ , "" ) ;
2026-04-15 09:19:16 +02:00
const countyAliases = COUNTY_FILTER_ALIASES [ selectedArea ] ;
2026-04-12 10:11:23 +02:00
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 ) ) ) ||
2026-04-15 09:19:16 +02:00
( areaFilter . startsWith ( "county:" ) &&
( countyAliases ? countyAliases . includes ( countySlug ) : countySlug === selectedArea ) ) ;
2026-04-12 10:11:23 +02:00
const matchesStatus = ! statusFilter || normalizedStatuses . includes ( statusFilter ) ;
const matchesHoles = matchesHoleFilter ( holeValue , holeFilter ) ;
const matchesSpecial = matchesSpecialFilter ( specialFilter , {
hasGolfamore ,
hasNSG ,
2026-04-26 11:29:35 +02:00
hasGolfPackages ,
2026-04-12 10:11:23 +02:00
hasSimulator ,
} ) ;
2026-04-19 10:24:33 +02:00
const selectedWeatherDayOffset = Number . parseInt ( weatherDayFilter , 10 ) ;
const weatherDay = Number . isNaN ( selectedWeatherDayOffset )
? null
: weatherForecast . find ( ( entry ) = > Number ( entry ? . day_offset ) === selectedWeatherDayOffset ) ;
const matchesWeather = ! weatherDayFilter || Boolean ( weatherDay ? . dry_daylight ) ;
2026-04-16 09:58:08 +02:00
const matchesArchitect = ! architectFilter || architectKey === architectFilter ;
2026-04-12 10:11:23 +02:00
return {
. . . facility ,
holeValue ,
2026-04-19 12:33:05 +02:00
countySlug ,
regions ,
statuses ,
2026-04-12 10:11:23 +02:00
primaryStatus ,
hasGolfamore ,
hasNSG ,
2026-04-26 11:29:35 +02:00
hasGolfPackages ,
2026-04-19 12:33:05 +02:00
hasSimulator ,
hasDrivingRange ,
hasVtg ,
2026-04-12 10:11:23 +02:00
distance ,
lastUpdatedTs ,
matchesSearch ,
matchesArea ,
matchesStatus ,
matchesHoles ,
matchesSpecial ,
2026-04-19 10:24:33 +02:00
matchesWeather ,
2026-04-16 09:58:08 +02:00
matchesArchitect ,
2026-04-12 10:11:23 +02:00
} ;
} )
. filter (
( facility ) = >
facility . matchesSearch &&
facility . matchesArea &&
facility . matchesStatus &&
facility . matchesHoles &&
2026-04-16 09:58:08 +02:00
facility . matchesSpecial &&
2026-04-19 10:24:33 +02:00
facility . matchesWeather &&
2026-04-24 09:37:01 +02:00
facility . matchesArchitect
2026-04-12 10:11:23 +02:00
)
. 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-19 10:24:33 +02:00
} , [
areaFilter ,
architectFilter ,
holeFilter ,
initialFacilities ,
searchQuery ,
sortMethod ,
specialFilter ,
statusFilter ,
userLocation ,
weatherDayFilter ,
] ) ;
2026-04-16 09:58:08 +02:00
const filtersCount = [
areaFilter ,
statusFilter ,
holeFilter ,
specialFilter ,
2026-04-19 10:24:33 +02:00
weatherDayFilter ,
2026-04-16 09:58:08 +02:00
architectFilter ,
searchQuery . trim ( ) ,
] . filter ( Boolean ) . length ;
2026-04-12 10:11:23 +02:00
const summaryText = ` ${ processedFacilities . length } baner • ${ getAreaLabel ( areaFilter , countyOptions ) } ${
filtersCount > 0 ? ` • ${ filtersCount } aktive filtre ` : ""
} ` ;
const labelClassName = variant === "home" ? "text-white/70" : "text-[#617063]" ;
2026-04-19 09:49:47 +02:00
const isCollapsibleHomeSearch = variant === "home" ;
const searchPanelOpen = ! isCollapsibleHomeSearch || isMobileSearchOpen ;
2026-04-24 09:37:01 +02:00
const handleFacilitySelect = ( slug : string ) = > {
if ( ! slug ) return ;
router . push ( ` /golfbaner/ ${ slug } ` ) ;
} ;
2026-04-19 09:49:47 +02:00
useEffect ( ( ) = > {
if ( isCollapsibleHomeSearch && filtersCount > 0 ) {
setIsMobileSearchOpen ( true ) ;
}
} , [ filtersCount , isCollapsibleHomeSearch ] ) ;
2026-02-26 09:20:51 +01:00
2026-04-19 12:33:05 +02:00
useEffect ( ( ) = > {
onFilteredFacilitiesChange ? . ( processedFacilities ) ;
} , [ onFilteredFacilitiesChange , processedFacilities ] ) ;
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" >
2026-04-12 15:57:37 +02:00
{ variant === "catalog" && ! hideTitleBlock && (
2026-04-12 10:11:23 +02:00
< 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" >
2026-04-19 09:49:47 +02:00
{ isCollapsibleHomeSearch ? (
< >
< button
type = "button"
onClick = { ( ) = > setIsMobileSearchOpen ( ( current ) = > ! current ) }
aria - expanded = { searchPanelOpen }
className = "flex w-full items-center justify-between gap-4 rounded-[1.4rem] border border-white/12 bg-white/6 px-4 py-4 text-left transition hover:bg-white/10 md:hidden"
>
2026-04-19 12:33:05 +02:00
< span className = "section-title text-[1.7rem] text-white" > { filterHeading } < / span >
2026-04-19 09:49:47 +02:00
< span
className = { ` inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white transition-transform duration-300 ${
searchPanelOpen ? "rotate-180" : ""
} ` }
aria - hidden = "true"
>
< svg className = "h-5 w-5" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.2" >
< path d = "m6 9 6 6 6-6" strokeLinecap = "round" strokeLinejoin = "round" / >
< / svg >
< / span >
< / button >
< div className = "hidden md:block" >
2026-04-19 12:33:05 +02:00
< h2 className = "section-title text-3xl sm:text-4xl" > { filterHeading } < / h2 >
2026-04-19 09:49:47 +02:00
< / div >
< / >
) : (
< h2 className = "section-title text-3xl sm:text-4xl" > Filtrer oversikten < / h2 >
) }
2026-04-12 10:11:23 +02:00
< / div >
2026-04-19 09:49:47 +02:00
< div
className = { ` overflow-hidden transition-[max-height,opacity,margin] duration-300 ease-out md:!visible md:!mt-0 md:!max-h-none md:!opacity-100 ${
isCollapsibleHomeSearch
? searchPanelOpen
? "visible mt-0 max-h-[1200px] opacity-100"
: "invisible -mt-2 max-h-0 opacity-0 md:visible"
: "visible mt-0 max-h-none opacity-100"
} ` }
aria - hidden = { isCollapsibleHomeSearch && ! searchPanelOpen }
>
2026-04-19 10:24:33 +02:00
< div className = "grid gap-3 md:grid-cols-2 xl:grid-cols-5" >
2026-04-19 09:49:47 +02:00
{ ! fixedAreaFilter && (
< 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 = "under-utvikling" > Under utvikling < / option >
< option value = "nedlagt" > Nedlagt < / option >
< option value = "ukjent" > Ukjent status < / option >
< / FieldSelect >
< FieldSelect label = "Antall hull" value = { holeFilter } onChange = { setHoleFilter } labelClassName = { labelClassName } >
< option value = "" > Alle antall hull < / 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 >
2026-04-19 10:24:33 +02:00
< FieldSelect
label = "Ikke regn meldt"
value = { weatherDayFilter }
onChange = { setWeatherDayFilter }
labelClassName = { labelClassName }
>
2026-04-24 09:17:14 +02:00
{ weatherDayOptions . map ( ( option ) = > (
2026-04-19 10:24:33 +02:00
< option key = { option . value || "all" } value = { option . value } >
{ option . label }
< / option >
) ) }
< / FieldSelect >
2026-04-26 11:29:35 +02:00
< FieldSelect label = "NSG / GOLFAMORE / GOLFPAKKE" value = { specialFilter } onChange = { setSpecialFilter } labelClassName = { labelClassName } >
2026-04-19 10:24:33 +02:00
< option value = "" > Ikke hensyntatt < / option >
2026-04-19 09:49:47 +02:00
< option value = "golfamore" > Golfamore < / option >
< option value = "nsg" > Seniorgolf / NSG < / option >
2026-04-26 11:29:35 +02:00
< option value = "golfpakke" > Tilbyr golfpakke < / option >
2026-04-19 09:49:47 +02:00
< / FieldSelect >
< / div >
< div className = "mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)_220px_auto]" >
< FieldSelect
label = "Arkitekt"
value = { architectFilter }
onChange = { setArchitectFilter }
labelClassName = { labelClassName }
>
< option value = "" > Alle arkitekter < / option >
{ architectOptions . map ( ( option ) = > (
< option key = { option . value } value = { option . value } >
2026-04-12 15:57:37 +02:00
{ option . label }
< / option >
) ) }
< / FieldSelect >
2026-04-12 10:11:23 +02:00
2026-04-19 09:49:47 +02:00
< FieldSelect
label = "Golfanlegg"
2026-04-24 09:37:01 +02:00
value = ""
onChange = { handleFacilitySelect }
2026-04-19 09:49:47 +02:00
labelClassName = { labelClassName }
>
2026-04-24 09:37:01 +02:00
< option value = "" > Gå direkte til golfanlegg < / option >
2026-04-19 09:49:47 +02:00
{ facilityOptions . map ( ( option ) = > (
< option key = { option . value } value = { option . value } >
{ option . label }
< / option >
) ) }
< / FieldSelect >
2026-04-12 10:11:23 +02:00
2026-04-19 09:49:47 +02:00
< 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 ( fixedAreaFilter ) ;
setStatusFilter ( "" ) ;
setHoleFilter ( "" ) ;
setSpecialFilter ( "" ) ;
2026-04-19 10:24:33 +02:00
setWeatherDayFilter ( "" ) ;
2026-04-19 09:49:47 +02:00
setArchitectFilter ( "" ) ;
setSortMethod ( userLocation ? "dist" : "updated" ) ;
} }
className = { ` btn btn-md mt-[1.72rem] h-[52px] ${
variant === "home" ? "btn-primary" : "btn-secondary"
} ` }
>
Nullstill
< / button >
< / div >
2026-04-19 10:24:33 +02:00
< p className = { ` mt-3 text-xs leading-5 ${ variant === "home" ? "text-white/70" : "text-[#617063]" } ` } >
Værfilteret bygger på prognosen fra MET akkurat nå . Lengre fram i tid gir lavere sikkerhet .
< / p >
2026-04-19 09:49:47 +02:00
< 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]"
2026-04-12 10:11:23 +02:00
} ` }
>
2026-04-19 09:49:47 +02:00
< 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-04-12 10:11:23 +02:00
< / 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" >
2026-04-20 09:39:30 +02:00
{ processedFacilities . map ( ( facility ) = > {
const statusBadges = buildFacilityStatusBadges ( facility . statuses ) ;
return (
< article
key = { facility . id }
className = "surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
>
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" >
2026-04-20 09:39:30 +02:00
{ statusBadges . map ( ( badge ) = > (
< span
key = { badge . label }
className = { ` rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
STATUS_CLASSES [ badge . status ] || STATUS_CLASSES . ukjent
} ` }
>
{ badge . label }
< / 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 >
2026-04-19 12:33:05 +02:00
< div
className = "mt-2 text-[15px] italic leading-7 text-[#7B3D2C] [&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
style = { noteClampStyle }
dangerouslySetInnerHTML = { { __html : sanitizeRichText ( facility . footnote ) } }
/ >
2026-04-12 15:19:39 +02:00
< / 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-18 09:00:16 +02:00
{ facility . phone ? (
< a
href = { ` tel: ${ formatPhoneHref ( facility . phone ) } ` }
className = "truncate text-[#617063] transition hover:text-[#FF5722]"
aria - label = { ` Ring ${ facility . name } på ${ facility . phone } ` }
>
{ facility . phone }
< / a >
) : (
< span className = "truncate text-[#617063]" > { facility . city || "Se detaljer" } < / span >
) }
2026-04-12 14:55:09 +02:00
< 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]" >
2026-04-14 16:31:28 +02:00
Baneprofil
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-20 09:39:30 +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 >
) ;
}