Nye-TeeOff/frontend/src/app/FacilitySearch.tsx

900 lines
35 KiB
TypeScript
Raw Normal View History

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";
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;
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;
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;
fixedAreaFilter?: string;
hideTitleBlock?: boolean;
2026-04-12 10:11:23 +02:00
};
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";
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(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const sanitizeHref = (value: string) => {
const href = value.trim();
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
};
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 {
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<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">`);
})
.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";
const noteClampStyle: CSSProperties = {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 3,
overflow: "hidden",
};
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 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.",
fixedAreaFilter = "",
hideTitleBlock = false,
2026-04-12 10:11:23 +02:00
}: FacilitySearchProps) {
2026-02-26 09:20:51 +01:00
const [searchQuery, setSearchQuery] = useState("");
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("");
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(() => {
setAreaFilter(fixedAreaFilter);
}, [fixedAreaFilter]);
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" && !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">
<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">
{!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>
)}
2026-04-12 10:11:23 +02:00
<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}>
2026-04-14 16:31:28 +02:00
<option value="">Alle golfbaner</option>
2026-04-12 10:11:23 +02:00
<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(fixedAreaFilter);
2026-04-12 10:11:23 +02:00
setStatusFilter("");
setHoleFilter("");
setSpecialFilter("");
setSortMethod(userLocation ? "dist" : "updated");
}}
2026-04-14 16:31:28 +02:00
className={`btn btn-md mt-[1.72rem] h-[52px] ${
variant === "home" ? "btn-primary" : "btn-secondary"
2026-04-12 10:11:23 +02:00
}`}
>
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 .</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) => (
<article
2026-04-12 10:11:23 +02:00
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 10:11:23 +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
</Link>
<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
{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>
)}
{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
<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">
{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
</Link>
2026-03-06 13:39:11 +01:00
</div>
2026-02-26 09:20:51 +01:00
</div>
</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
}
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"
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>
);
}