"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; architect?: string | null; description?: string | null; 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; fixedAreaFilter?: string; hideTitleBlock?: boolean; }; type SpecialFlags = { hasGolfamore: boolean; hasNSG: boolean; hasSimulator: boolean; }; const HIDDEN_COUNTY_SLUGS = new Set(["innlandet", "viken"]); 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 COUNTY_FILTER_ALIASES: Record = { trondelag: ["trondelag", "nord-trondelag", "sor-trondelag"], }; 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: "county:trondelag", label: "Trøndelag" }, { 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 = { 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 ?? "") .replace(/[æøå]/gi, (char) => { const normalized = char.toLowerCase(); if (normalized === "æ") return "ae"; if (normalized === "ø") return "o"; if (normalized === "å") return "a"; return normalized; }) .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 escapeHtml = (value: string) => value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const sanitizeHref = (value: string) => { const href = value.trim(); return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#"; }; const isInternalTeeoffHref = (href: string) => { if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { return true; } try { const url = new URL(href, "https://nye.teeoff.no"); return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no"); } catch { return false; } }; const sanitizeRichText = (value: string | null | undefined) => { const source = String(value || "").replace(/\r\n?/g, "\n"); if (!source.trim()) return ""; const placeholders = new Map(); 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("
")) .replace(/<\s*(strong|b)\s*>/gi, () => keep("")) .replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("")) .replace(/<\s*(em|i)\s*>/gi, () => keep("")) .replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("")) .replace(/<\s*p\s*>/gi, () => keep("

")) .replace(/<\s*\/\s*p\s*>/gi, () => keep("

")) .replace(/<\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`)) .replace(/<\s*\/\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(``)) .replace(/<\s*li\s*>/gi, () => keep("
  • ")) .replace(/<\s*\/\s*li\s*>/gi, () => keep("
  • ")) .replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => { const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#"); if (isInternalTeeoffHref(href)) { return keep(``); } return keep(``); }) .replace(/<\s*\/\s*a\s*>/gi, () => keep("")); safe = escapeHtml(safe).replace(/\n/g, "
    "); for (const [token, html] of placeholders) { safe = safe.replaceAll(token, html); } return safe; }; 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; 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-7 w-7 items-center justify-center rounded-[0.8rem] 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.", fixedAreaFilter = "", hideTitleBlock = false, }: FacilitySearchProps) { const [searchQuery, setSearchQuery] = useState(""); const [areaFilter, setAreaFilter] = useState(fixedAreaFilter); const [statusFilter, setStatusFilter] = useState(""); const [holeFilter, setHoleFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState(""); const [architectFilter, setArchitectFilter] = useState(""); const [facilityFilter, setFacilityFilter] = useState(""); const [sortMethod, setSortMethod] = useState("updated"); const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); useEffect(() => { setAreaFilter(fixedAreaFilter); }, [fixedAreaFilter]); 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 && !HIDDEN_COUNTY_SLUGS.has(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 architectOptions = useMemo(() => { const unique = new Map(); 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]); 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 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 architectKey = normalizeText(facility.architect || ""); 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, facility.architect, 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 (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 countyAliases = COUNTY_FILTER_ALIASES[selectedArea]; 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:") && (countyAliases ? countyAliases.includes(countySlug) : countySlug === selectedArea)); const matchesStatus = !statusFilter || normalizedStatuses.includes(statusFilter); const matchesHoles = matchesHoleFilter(holeValue, holeFilter); const matchesSpecial = matchesSpecialFilter(specialFilter, { hasGolfamore, hasNSG, hasSimulator, }); const matchesArchitect = !architectFilter || architectKey === architectFilter; const matchesFacility = !facilityFilter || facility.slug === facilityFilter; return { ...facility, holeValue, primaryStatus, hasGolfamore, hasNSG, distance, lastUpdatedTs, matchesSearch, matchesArea, matchesStatus, matchesHoles, matchesSpecial, matchesArchitect, matchesFacility, }; }) .filter( (facility) => facility.matchesSearch && facility.matchesArea && facility.matchesStatus && facility.matchesHoles && facility.matchesSpecial && facility.matchesArchitect && facility.matchesFacility ) .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, architectFilter, facilityFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); const filtersCount = [ areaFilter, statusFilter, holeFilter, specialFilter, architectFilter, facilityFilter, 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" && !hideTitleBlock && (

    {eyebrow}

    {title}

    {intro}

    )}

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

    {!fixedAreaFilter && ( {areaOptions.map((option) => ( ))} )}
    {architectOptions.map((option) => ( ))} {facilityOptions.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.status_updated_at && (
    {formatUpdatedDate(facility.status_updated_at)}
    )}

    {facility.city} • {facility.county}

    {facility.name}

    {facility.holeValue || "--"} hull {facility.banetype || "Banetype ukjent"}
    {Number.isFinite(facility.distance) && ( {Math.round(facility.distance)} km unna )}
    {facility.footnote && (

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

    {facility.footnote}

    )} {String(facility.description || "").trim() && (
    )}
    {facility.phone ? facility.phone : facility.city || "Se detaljer"}
    {facility.website_url && ( )} {facility.golfbox_booking_url && ( )} {facility.golfbox_tournament_url && ( )} {buildMapUrl(facility.lat, facility.lng) && ( )} {facility.weather_url && ( )}
    Baneprofil
    ))}
    )}
    ); } 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 ( ); }