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 } 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;
|
|
|
|
|
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;
|
|
|
|
|
status_updated_at?: string | null;
|
|
|
|
|
amenities?: unknown;
|
|
|
|
|
golfamore_data?: unknown;
|
|
|
|
|
nsg_data?: unknown;
|
|
|
|
|
vtg_datoer?: unknown;
|
|
|
|
|
course_statuses?: unknown;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type FacilitySearchProps = {
|
|
|
|
|
initialFacilities: Facility[];
|
|
|
|
|
variant?: Variant;
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
title?: string;
|
|
|
|
|
intro?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SpecialFlags = {
|
|
|
|
|
hasGolfamore: boolean;
|
|
|
|
|
hasNSG: boolean;
|
|
|
|
|
hasSimulator: boolean;
|
|
|
|
|
hasDrivingRange: boolean;
|
|
|
|
|
hasVtg: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AREA_GROUPS: Record<string, string[]> = {
|
|
|
|
|
"nord-norge": ["finnmark", "troms", "nordland"],
|
|
|
|
|
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
|
|
|
|
vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"],
|
|
|
|
|
sorlandet: ["vest-agder", "aust-agder", "agder"],
|
|
|
|
|
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
|
|
|
|
|
"oslo-og-akershus": ["akershus", "oslo", "viken"],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const HIERARCHICAL_AREA_OPTIONS = [
|
|
|
|
|
{ value: "", label: "Hele Norge" },
|
|
|
|
|
{ value: "region:nord-norge", label: "Nord-Norge" },
|
|
|
|
|
{ value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" },
|
|
|
|
|
{ value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" },
|
|
|
|
|
{ value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" },
|
|
|
|
|
{ value: "region:midt-norge", label: "Midt-Norge" },
|
|
|
|
|
{ value: "county:nord-trondelag", label: "\u00A0\u00A0\u00A0Nord-Trøndelag" },
|
|
|
|
|
{ value: "county:sor-trondelag", label: "\u00A0\u00A0\u00A0Sør-Trøndelag" },
|
|
|
|
|
{ value: "county:trondelag", label: "\u00A0\u00A0\u00A0Trøndelag" },
|
|
|
|
|
{ value: "region:vestlandet", label: "Vestlandet" },
|
|
|
|
|
{ value: "county:more-og-romsdal", label: "\u00A0\u00A0\u00A0Møre og Romsdal" },
|
|
|
|
|
{ value: "county:sogn-og-fjordane", label: "\u00A0\u00A0\u00A0Sogn og Fjordane" },
|
|
|
|
|
{ value: "county:hordaland", label: "\u00A0\u00A0\u00A0Hordaland" },
|
|
|
|
|
{ value: "county:rogaland", label: "\u00A0\u00A0\u00A0Rogaland" },
|
|
|
|
|
{ value: "county:vestland", label: "\u00A0\u00A0\u00A0Vestland" },
|
|
|
|
|
{ value: "region:sorlandet", label: "Sørlandet" },
|
|
|
|
|
{ value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" },
|
|
|
|
|
{ value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" },
|
|
|
|
|
{ value: "county:agder", label: "\u00A0\u00A0\u00A0Agder" },
|
|
|
|
|
{ value: "region:ostlandet", label: "Østlandet" },
|
|
|
|
|
{ value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" },
|
|
|
|
|
{ value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" },
|
|
|
|
|
{ value: "county:ostfold", label: "\u00A0\u00A0\u00A0Østfold" },
|
|
|
|
|
{ value: "county:buskerud", label: "\u00A0\u00A0\u00A0Buskerud" },
|
|
|
|
|
{ value: "county:hedmark", label: "\u00A0\u00A0\u00A0Hedmark" },
|
|
|
|
|
{ value: "county:oppland", label: "\u00A0\u00A0\u00A0Oppland" },
|
|
|
|
|
{ value: "region:oslo-og-akershus", label: "\u00A0\u00A0\u00A0Oslo og Akershus" },
|
|
|
|
|
{ value: "county:akershus", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" },
|
|
|
|
|
{ value: "county:oslo", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" },
|
|
|
|
|
{ value: "county:innlandet", label: "\u00A0\u00A0\u00A0Innlandet" },
|
|
|
|
|
{ value: "county:viken", label: "\u00A0\u00A0\u00A0Viken" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const STATUS_ORDER = [
|
|
|
|
|
"aapen",
|
|
|
|
|
"aapen_med_vintergreener",
|
|
|
|
|
"stenger_snart",
|
|
|
|
|
"aapner_snart",
|
|
|
|
|
"ukjent",
|
|
|
|
|
"stengt",
|
|
|
|
|
"under_utvikling",
|
|
|
|
|
"nedlagt",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const STATUS_CLASSES: Record<string, string> = {
|
|
|
|
|
aapen: "bg-[#8BC34A] text-white",
|
|
|
|
|
aapen_med_vintergreener: "bg-[#D2A63A] text-[#112015]",
|
|
|
|
|
stenger_snart: "bg-[#FF5722] text-white",
|
|
|
|
|
aapner_snart: "bg-sky-600 text-white",
|
|
|
|
|
stengt: "bg-[#B6473D] text-white",
|
|
|
|
|
under_utvikling: "bg-slate-600 text-white",
|
|
|
|
|
nedlagt: "bg-[#112015] text-white",
|
|
|
|
|
ukjent: "bg-[#D9DED5] text-[#112015]",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeText = (value: unknown) =>
|
|
|
|
|
String(value ?? "")
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.normalize("NFD")
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
|
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
|
|
|
.trim();
|
|
|
|
|
|
|
|
|
|
const normalizeStatus = (value: unknown) =>
|
|
|
|
|
String(value ?? "")
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.normalize("NFD")
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
|
|
|
.replace(/\s+/g, "_")
|
|
|
|
|
.replace(/[^a-z0-9_]+/g, "")
|
|
|
|
|
.trim();
|
|
|
|
|
|
|
|
|
|
const slugify = (value: unknown) =>
|
|
|
|
|
normalizeText(value)
|
|
|
|
|
.replace(/\s+/g, "-")
|
|
|
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
|
|
|
|
|
|
const parseJson = <T,>(value: unknown, fallback: T): T => {
|
|
|
|
|
if (!value) return fallback;
|
|
|
|
|
if (typeof value === "object") return value as T;
|
2026-02-26 09:20:51 +01:00
|
|
|
try {
|
2026-04-12 10:11:23 +02:00
|
|
|
return JSON.parse(String(value)) as T;
|
|
|
|
|
} catch {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
|
|
|
|
try {
|
|
|
|
|
const r = 6371;
|
|
|
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
|
|
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
|
|
|
const a =
|
|
|
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
|
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
|
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
|
|
|
Math.sin(dLon / 2) *
|
|
|
|
|
Math.sin(dLon / 2);
|
|
|
|
|
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
|
} catch {
|
|
|
|
|
return Number.POSITIVE_INFINITY;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasTruthyAmenity = (value: unknown) => {
|
|
|
|
|
const normalized = normalizeText(value);
|
|
|
|
|
return Boolean(normalized) && !["nei", "no", "false", "0", "ingen"].includes(normalized);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFacilityRegions = (county: string) => {
|
|
|
|
|
const countySlug = slugify(county);
|
|
|
|
|
return Object.entries(AREA_GROUPS)
|
|
|
|
|
.filter(([, counties]) => counties.includes(countySlug))
|
|
|
|
|
.map(([region]) => region);
|
|
|
|
|
};
|
2026-02-26 09:20:51 +01:00
|
|
|
|
2026-04-12 10:11:23 +02:00
|
|
|
const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
|
|
|
|
|
for (const candidate of STATUS_ORDER) {
|
|
|
|
|
if (statuses.some((status) => normalizeStatus(status.status) === candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "ukjent";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatUpdatedDate = (value: string | null | undefined) => {
|
|
|
|
|
if (!value) return "Ukjent";
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
if (Number.isNaN(date.getTime())) return "Ukjent";
|
|
|
|
|
return date.toLocaleDateString("nb-NO", {
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
month: "short",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent";
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
export default function FacilitySearch({
|
|
|
|
|
initialFacilities,
|
|
|
|
|
variant = "catalog",
|
|
|
|
|
eyebrow = "Golfbaner",
|
|
|
|
|
title = "Alle golfbaner samlet på ett sted",
|
|
|
|
|
intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero.",
|
|
|
|
|
}: FacilitySearchProps) {
|
2026-02-26 09:20:51 +01:00
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
2026-04-12 10:11:23 +02:00
|
|
|
const [areaFilter, setAreaFilter] = useState("");
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState("");
|
|
|
|
|
const [holeFilter, setHoleFilter] = useState("");
|
|
|
|
|
const [specialFilter, setSpecialFilter] = useState("");
|
|
|
|
|
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
|
|
|
|
|
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
2026-02-26 09:20:51 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-12 10:11:23 +02:00
|
|
|
if (!("geolocation" in navigator)) return;
|
|
|
|
|
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
(position) => {
|
|
|
|
|
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude });
|
|
|
|
|
setSortMethod((current) => (current === "updated" ? "dist" : current));
|
|
|
|
|
},
|
|
|
|
|
() => undefined,
|
|
|
|
|
{
|
|
|
|
|
enableHighAccuracy: false,
|
|
|
|
|
timeout: 8000,
|
|
|
|
|
maximumAge: 1000 * 60 * 30,
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-02-26 09:20:51 +01:00
|
|
|
}, []);
|
|
|
|
|
|
2026-04-12 10:11:23 +02:00
|
|
|
const countyOptions = useMemo(() => {
|
|
|
|
|
const unique = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) {
|
|
|
|
|
const label = String(facility?.county || "").trim();
|
|
|
|
|
const slug = slugify(label);
|
|
|
|
|
if (label && slug && !unique.has(slug)) unique.set(slug, label);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(unique.entries())
|
|
|
|
|
.map(([slug, label]) => ({ slug, label }))
|
|
|
|
|
.sort((a, b) => a.label.localeCompare(b.label, "nb"));
|
|
|
|
|
}, [initialFacilities]);
|
|
|
|
|
|
|
|
|
|
const areaOptions = useMemo(() => {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const options = HIERARCHICAL_AREA_OPTIONS.filter((option) => {
|
|
|
|
|
if (seen.has(option.value)) return false;
|
|
|
|
|
seen.add(option.value);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const county of countyOptions) {
|
|
|
|
|
const countyValue = `county:${county.slug}`;
|
|
|
|
|
if (!seen.has(countyValue)) {
|
|
|
|
|
options.push({ value: countyValue, label: county.label });
|
|
|
|
|
seen.add(countyValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return options;
|
|
|
|
|
}, [countyOptions]);
|
|
|
|
|
|
|
|
|
|
const processedFacilities = useMemo(() => {
|
2026-02-27 08:53:14 +01:00
|
|
|
if (!Array.isArray(initialFacilities)) return [];
|
2026-02-26 09:20:51 +01:00
|
|
|
|
2026-04-12 10:11:23 +02:00
|
|
|
const stopWords = new Set(["i", "pa", "for", "med", "av", "og", "de", "den", "det", "bane", "baner"]);
|
|
|
|
|
|
|
|
|
|
return initialFacilities
|
|
|
|
|
.map((facility) => {
|
|
|
|
|
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
|
|
|
|
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
|
|
|
|
|
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
|
|
|
|
|
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
|
|
|
|
|
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
|
|
|
|
|
const statuses =
|
|
|
|
|
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
|
|
|
|
? rawStatuses
|
|
|
|
|
: [{ status: "ukjent", name: "Hovedbane" }];
|
|
|
|
|
|
|
|
|
|
const countySlug = slugify(facility.county || "");
|
|
|
|
|
const regions = getFacilityRegions(facility.county || "");
|
|
|
|
|
const holeValue = String(amenities.antall_hull || "").trim();
|
|
|
|
|
const primaryStatus = getPrimaryStatus(statuses);
|
|
|
|
|
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
|
|
|
|
|
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
|
|
|
|
|
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
|
|
|
|
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
|
|
|
|
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
|
|
|
|
const hasVtg =
|
|
|
|
|
Boolean(facility.vtg_pris) ||
|
|
|
|
|
Boolean(facility.vtg_lenke) ||
|
|
|
|
|
Boolean(facility.vtg_beskrivelse) ||
|
|
|
|
|
(Array.isArray(vtgDates) && vtgDates.length > 0);
|
|
|
|
|
|
|
|
|
|
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
|
|
|
|
|
const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0;
|
|
|
|
|
const distance =
|
|
|
|
|
userLocation && facility.lat && facility.lng
|
|
|
|
|
? getDistance(userLocation.lat, userLocation.lng, facility.lat, facility.lng)
|
|
|
|
|
: Number.POSITIVE_INFINITY;
|
|
|
|
|
|
|
|
|
|
let searchBlob = [
|
|
|
|
|
facility.name,
|
|
|
|
|
facility.city,
|
|
|
|
|
facility.county,
|
|
|
|
|
facility.banetype,
|
|
|
|
|
holeValue,
|
|
|
|
|
...statuses.map((status) => status.name),
|
|
|
|
|
...regions,
|
|
|
|
|
]
|
|
|
|
|
.map((value) => normalizeText(value))
|
|
|
|
|
.join(" ");
|
|
|
|
|
|
|
|
|
|
if (hasGolfamore) searchBlob += " golfamore";
|
|
|
|
|
if (hasNSG) searchBlob += " nsg seniorgolf";
|
|
|
|
|
if (hasSimulator) searchBlob += " simulator";
|
|
|
|
|
if (hasDrivingRange) searchBlob += " drivingrange range";
|
|
|
|
|
if (hasVtg) searchBlob += " vtg veien til golf nybegynnerkurs";
|
|
|
|
|
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
|
|
|
|
|
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
|
|
|
|
|
if (normalizedStatuses.includes("aapen_med_vintergreener")) searchBlob += " vinter vintergreener";
|
|
|
|
|
|
|
|
|
|
const words = normalizeText(searchQuery)
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter((word) => word && !stopWords.has(word));
|
|
|
|
|
|
|
|
|
|
const selectedArea = areaFilter.replace(/^(region:|county:)/, "");
|
|
|
|
|
const matchesSearch = words.every((word) => searchBlob.includes(word));
|
|
|
|
|
const matchesArea =
|
|
|
|
|
!areaFilter ||
|
|
|
|
|
(areaFilter.startsWith("region:") &&
|
|
|
|
|
(regions.includes(selectedArea) ||
|
|
|
|
|
(AREA_GROUPS[selectedArea] ? AREA_GROUPS[selectedArea].includes(countySlug) : false))) ||
|
|
|
|
|
(areaFilter.startsWith("county:") && countySlug === selectedArea);
|
|
|
|
|
const matchesStatus = !statusFilter || normalizedStatuses.includes(statusFilter);
|
|
|
|
|
const matchesHoles = matchesHoleFilter(holeValue, holeFilter);
|
|
|
|
|
const matchesSpecial = matchesSpecialFilter(specialFilter, {
|
|
|
|
|
hasGolfamore,
|
|
|
|
|
hasNSG,
|
|
|
|
|
hasSimulator,
|
|
|
|
|
hasDrivingRange,
|
|
|
|
|
hasVtg,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...facility,
|
|
|
|
|
holeValue,
|
|
|
|
|
primaryStatus,
|
|
|
|
|
hasGolfamore,
|
|
|
|
|
hasNSG,
|
|
|
|
|
hasVtg,
|
|
|
|
|
distance,
|
|
|
|
|
lastUpdatedTs,
|
|
|
|
|
matchesSearch,
|
|
|
|
|
matchesArea,
|
|
|
|
|
matchesStatus,
|
|
|
|
|
matchesHoles,
|
|
|
|
|
matchesSpecial,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter(
|
|
|
|
|
(facility) =>
|
|
|
|
|
facility.matchesSearch &&
|
|
|
|
|
facility.matchesArea &&
|
|
|
|
|
facility.matchesStatus &&
|
|
|
|
|
facility.matchesHoles &&
|
|
|
|
|
facility.matchesSpecial
|
|
|
|
|
)
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
if (sortMethod === "dist") {
|
|
|
|
|
if (a.distance !== b.distance) return a.distance - b.distance;
|
|
|
|
|
return a.name.localeCompare(b.name, "nb");
|
|
|
|
|
}
|
|
|
|
|
if (sortMethod === "updated") {
|
|
|
|
|
if (a.lastUpdatedTs !== b.lastUpdatedTs) return b.lastUpdatedTs - a.lastUpdatedTs;
|
|
|
|
|
return a.name.localeCompare(b.name, "nb");
|
|
|
|
|
}
|
|
|
|
|
return a.name.localeCompare(b.name, "nb");
|
2026-03-06 13:39:11 +01:00
|
|
|
});
|
2026-04-12 10:11:23 +02:00
|
|
|
}, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]);
|
2026-03-06 13:39:11 +01:00
|
|
|
|
2026-04-12 10:11:23 +02:00
|
|
|
const filtersCount = [areaFilter, statusFilter, holeFilter, specialFilter, searchQuery.trim()].filter(Boolean).length;
|
|
|
|
|
const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${
|
|
|
|
|
filtersCount > 0 ? ` • ${filtersCount} aktive filtre` : ""
|
|
|
|
|
}`;
|
|
|
|
|
const labelClassName = variant === "home" ? "text-white/70" : "text-[#617063]";
|
2026-02-26 09:20:51 +01:00
|
|
|
|
|
|
|
|
return (
|
2026-04-12 10:11:23 +02:00
|
|
|
<section className="mx-auto max-w-[1400px] px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
|
|
|
|
|
{variant === "catalog" && (
|
|
|
|
|
<div className="mb-8 max-w-4xl">
|
|
|
|
|
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">{eyebrow}</p>
|
|
|
|
|
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{title}</h1>
|
|
|
|
|
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{intro}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className={getSearchShellClasses(variant)}>
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
<h2 className="section-title text-3xl sm:text-4xl">
|
|
|
|
|
{variant === "home" ? "Søk golfbaner" : "Filtrer oversikten"}
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
|
|
|
<FieldSelect label="Område" value={areaFilter} onChange={setAreaFilter} labelClassName={labelClassName}>
|
|
|
|
|
{areaOptions.map((option) => (
|
|
|
|
|
<option key={option.value || "all"} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</FieldSelect>
|
|
|
|
|
|
|
|
|
|
<FieldSelect label="Banestatus" value={statusFilter} onChange={setStatusFilter} labelClassName={labelClassName}>
|
|
|
|
|
<option value="">Alle statuser</option>
|
|
|
|
|
<option value="aapen">Åpne baner</option>
|
|
|
|
|
<option value="aapen_med_vintergreener">Vintergreener</option>
|
|
|
|
|
<option value="stenger_snart">Stenger snart</option>
|
|
|
|
|
<option value="aapner_snart">Åpner snart</option>
|
|
|
|
|
<option value="stengt">Stengt</option>
|
|
|
|
|
<option value="ukjent">Ukjent status</option>
|
|
|
|
|
</FieldSelect>
|
|
|
|
|
|
|
|
|
|
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
|
|
|
|
|
<option value="">Alle anlegg</option>
|
|
|
|
|
<option value="18-plus">18 hull eller mer</option>
|
|
|
|
|
<option value="18">Nøyaktig 18 hull</option>
|
|
|
|
|
<option value="9">9 hull</option>
|
|
|
|
|
<option value="6-12">6 eller 12 hull</option>
|
|
|
|
|
<option value="under-utvikling">Under utvikling</option>
|
|
|
|
|
</FieldSelect>
|
|
|
|
|
|
|
|
|
|
<FieldSelect label="Ekstra" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
|
|
|
|
<option value="">Ingen tillegg</option>
|
|
|
|
|
<option value="golfamore">Golfamore</option>
|
|
|
|
|
<option value="nsg">Seniorgolf / NSG</option>
|
|
|
|
|
<option value="simulator">Simulator</option>
|
|
|
|
|
<option value="drivingrange">Drivingrange</option>
|
|
|
|
|
<option value="vtg">Tilbyr VTG</option>
|
|
|
|
|
</FieldSelect>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_auto]">
|
|
|
|
|
<FieldInput
|
|
|
|
|
label="Søk"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
placeholder='For eksempel "åpne baner i Oslo"'
|
|
|
|
|
onChange={setSearchQuery}
|
|
|
|
|
labelClassName={labelClassName}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<FieldSelect
|
|
|
|
|
label="Sortering"
|
|
|
|
|
value={sortMethod}
|
|
|
|
|
onChange={(value) => setSortMethod(value as SortMethod)}
|
|
|
|
|
labelClassName={labelClassName}
|
|
|
|
|
>
|
|
|
|
|
<option value="updated">Sist oppdatert</option>
|
|
|
|
|
<option value="alpha">Alfabetisk</option>
|
|
|
|
|
<option value="dist">Nærmest deg</option>
|
|
|
|
|
</FieldSelect>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
setAreaFilter("");
|
|
|
|
|
setStatusFilter("");
|
|
|
|
|
setHoleFilter("");
|
|
|
|
|
setSpecialFilter("");
|
|
|
|
|
setSortMethod(userLocation ? "dist" : "updated");
|
|
|
|
|
}}
|
|
|
|
|
className={`mt-[1.72rem] h-[52px] rounded-2xl px-5 text-[11px] font-extrabold uppercase tracking-[0.2em] transition ${
|
|
|
|
|
variant === "home" ? "bg-[#FF5722] text-white hover:bg-[#C94F2D]" : "bg-[#25312A] text-white hover:bg-[#39443B]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Nullstill
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
className={`mt-4 rounded-[1.2rem] px-4 py-3 text-sm font-bold ${
|
|
|
|
|
variant === "home" ? "bg-white/10 text-white/90" : "bg-[#F3F6EE] text-[#617063]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span>{summaryText}</span>
|
|
|
|
|
<span className={`ml-2 ${variant === "home" ? "text-white/65" : "text-[#839184]"}`}>
|
|
|
|
|
{sortMethod === "dist" && userLocation
|
|
|
|
|
? "Sortert etter avstand fra deg."
|
|
|
|
|
: sortMethod === "updated"
|
|
|
|
|
? "Sortert etter sist oppdatert."
|
|
|
|
|
: "Sortert alfabetisk."}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-02-26 09:20:51 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-12 10:11:23 +02:00
|
|
|
{processedFacilities.length === 0 ? (
|
|
|
|
|
<div className="surface-card mt-6 rounded-[2rem] px-6 py-12 text-center">
|
|
|
|
|
<p className="text-lg font-extrabold text-[#112015]">Ingen baner matcher filtrene akkurat nå.</p>
|
|
|
|
|
<p className="mt-2 text-sm text-[#617063]">Prøv å nullstille filtrene eller velg et større område.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
|
|
|
|
{processedFacilities.map((facility) => (
|
|
|
|
|
<Link
|
|
|
|
|
href={`/golfbaner/${facility.slug}`}
|
|
|
|
|
key={facility.id}
|
|
|
|
|
className="surface-card group overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
|
|
|
|
|
>
|
|
|
|
|
<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" />
|
|
|
|
|
|
|
|
|
|
<div className="absolute left-4 top-4 flex max-w-[calc(100%-2rem)] 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)}
|
|
|
|
|
</span>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-02-27 09:35:30 +01:00
|
|
|
</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 10:11:23 +02:00
|
|
|
<div className="space-y-5 p-5">
|
|
|
|
|
<div className="flex 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>
|
|
|
|
|
{facility.hasVtg && (
|
|
|
|
|
<span className="rounded-full bg-[#FFF0E9] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#C94F2D]">
|
|
|
|
|
VTG
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 rounded-[1.35rem] bg-[#F7F8F3] p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#839184]">Oppdatert</p>
|
|
|
|
|
<p className="mt-1 text-sm font-bold text-[#112015]">{formatUpdatedDate(facility.status_updated_at)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#839184]">Sortering</p>
|
|
|
|
|
<p className="mt-1 text-sm font-bold text-[#112015]">
|
|
|
|
|
{sortMethod === "dist" && Number.isFinite(facility.distance)
|
|
|
|
|
? `${Math.round(facility.distance)} km unna`
|
|
|
|
|
: sortMethod === "updated"
|
|
|
|
|
? "Nyeste status"
|
|
|
|
|
: "Alfabetisk"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between text-sm font-bold text-[#112015]">
|
|
|
|
|
<span className="text-[#617063]">{facility.phone ? facility.phone : "Se detaljer"}</span>
|
|
|
|
|
<span className="text-[#FF5722] transition group-hover:text-[#C94F2D]">Se anlegg →</span>
|
2026-03-06 13:39:11 +01:00
|
|
|
</div>
|
2026-02-26 09:20:51 +01:00
|
|
|
</div>
|
2026-02-27 09:35:30 +01:00
|
|
|
</Link>
|
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 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>
|
|
|
|
|
);
|
|
|
|
|
}
|