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

683 lines
26 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 } 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 .</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>
);
}