"use client"; import { STATUS_MAP } from "@/config/constants"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useMemo, useState, type CSSProperties } from "react"; type SortMethod = "updated" | "dist" | "alpha"; type Variant = "home" | "catalog"; type CourseStatus = { status?: string; name?: string; }; type Facility = { id: number; slug: string; name: string; city?: string | null; county?: string | null; banetype?: string | null; image_url?: string | null; phone?: string | null; website_url?: string | null; golfbox_booking_url?: string | null; golfbox_tournament_url?: string | null; weather_url?: string | null; 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; footnote?: string | null; footnote_updated_at?: string | null; 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 = { "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 = { 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 = (value: unknown, fallback: T): T => { if (!value) return fallback; if (typeof value === "object") return value as T; try { return JSON.parse(String(value)) as T; } catch { return fallback; } }; 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); }; 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"; 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}`; }; 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"; const noteClampStyle: CSSProperties = { display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 3, overflow: "hidden", }; const actionIconClassName = "flex h-10 w-10 items-center justify-center rounded-[0.95rem] border border-[#D5DDD1] bg-white text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"; 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) { const [searchQuery, setSearchQuery] = useState(""); const [areaFilter, setAreaFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [holeFilter, setHoleFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState(""); const [sortMethod, setSortMethod] = useState("updated"); const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); useEffect(() => { 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, } ); }, []); const countyOptions = useMemo(() => { const unique = new Map(); 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(); 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(() => { if (!Array.isArray(initialFacilities)) return []; const stopWords = new Set(["i", "pa", "for", "med", "av", "og", "de", "den", "det", "bane", "baner"]); return initialFacilities .map((facility) => { const amenities = parseJson>(facility.amenities, {}); const golfamoreData = parseJson>(facility.golfamore_data, {}); const nsgData = parseJson>(facility.nsg_data, {}); const vtgDates = parseJson(facility.vtg_datoer, []); const rawStatuses = parseJson(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"); }); }, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); 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]"; return (
{variant === "catalog" && (

{eyebrow}

{title}

{intro}

)}

{variant === "home" ? "Søk golfbaner" : "Filtrer oversikten"}

{areaOptions.map((option) => ( ))}
setSortMethod(value as SortMethod)} labelClassName={labelClassName} >
{summaryText} {sortMethod === "dist" && userLocation ? "Sortert etter avstand fra deg." : sortMethod === "updated" ? "Sortert etter sist oppdatert." : "Sortert alfabetisk."}
{processedFacilities.length === 0 ? (

Ingen baner matcher filtrene akkurat nå.

Prøv å nullstille filtrene eller velg et større område.

) : (
{processedFacilities.map((facility) => (
{facility.name}
{getStatusLabel(facility.primaryStatus)} {facility.hasGolfamore && ( Golfamore )} {facility.hasNSG && ( NSG )}

{facility.city} • {facility.county}

{facility.name}

{facility.holeValue || "--"} hull {facility.banetype || "Banetype ukjent"}
Oppdatert {formatUpdatedDate(facility.status_updated_at)} {sortMethod === "dist" && Number.isFinite(facility.distance) ? `${Math.round(facility.distance)} km unna` : sortMethod === "updated" ? "Nyeste status" : "Alfabetisk"}
{facility.footnote && (

{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}

{facility.footnote}

)}
{facility.website_url && ( )} {facility.golfbox_booking_url && ( )} {facility.golfbox_tournament_url && ( )} {buildMapUrl(facility.lat, facility.lng) && ( )} {facility.weather_url && ( )}
{facility.phone ? facility.phone : facility.city || "Se detaljer"} Se anlegg →
))}
)}
); } function ActionIcon({ type }: { type: "web" | "booking" | "trophy" | "pin" | "weather" }) { return ( ); } function FieldSelect({ label, value, onChange, labelClassName, children, }: { label: string; value: string; onChange: (value: string) => void; labelClassName: string; children: React.ReactNode; }) { return ( ); } function FieldInput({ label, value, placeholder, onChange, labelClassName, }: { label: string; value: string; placeholder: string; onChange: (value: string) => void; labelClassName: string; }) { return ( ); }